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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/README.md +1 -0
  2. package/README.zh-TW.md +1 -0
  3. package/dist/access-path.js +15 -6
  4. package/dist/access-path.js.map +1 -1
  5. package/dist/agent-cli-instructions.md +9 -0
  6. package/dist/backend/antigravity.d.ts +4 -1
  7. package/dist/backend/antigravity.js +24 -13
  8. package/dist/backend/antigravity.js.map +1 -1
  9. package/dist/backend/claude-code.d.ts +37 -0
  10. package/dist/backend/claude-code.js +47 -8
  11. package/dist/backend/claude-code.js.map +1 -1
  12. package/dist/backend/codex.d.ts +17 -0
  13. package/dist/backend/codex.js +279 -62
  14. package/dist/backend/codex.js.map +1 -1
  15. package/dist/backend/gemini-cli.js +4 -7
  16. package/dist/backend/gemini-cli.js.map +1 -1
  17. package/dist/backend/grok.d.ts +24 -0
  18. package/dist/backend/grok.js +57 -8
  19. package/dist/backend/grok.js.map +1 -1
  20. package/dist/backend/kiro.d.ts +27 -0
  21. package/dist/backend/kiro.js +96 -15
  22. package/dist/backend/kiro.js.map +1 -1
  23. package/dist/backend/opencode.js +4 -7
  24. package/dist/backend/opencode.js.map +1 -1
  25. package/dist/backend/types.d.ts +44 -0
  26. package/dist/backend/types.js +3 -3
  27. package/dist/backend/types.js.map +1 -1
  28. package/dist/channel/adapters/discord.d.ts +15 -0
  29. package/dist/channel/adapters/discord.js +94 -6
  30. package/dist/channel/adapters/discord.js.map +1 -1
  31. package/dist/channel/adapters/telegram.d.ts +21 -0
  32. package/dist/channel/adapters/telegram.js +132 -15
  33. package/dist/channel/adapters/telegram.js.map +1 -1
  34. package/dist/channel/ipc-bridge.d.ts +17 -1
  35. package/dist/channel/ipc-bridge.js +53 -17
  36. package/dist/channel/ipc-bridge.js.map +1 -1
  37. package/dist/channel/ipc-timeouts.d.ts +40 -0
  38. package/dist/channel/ipc-timeouts.js +58 -0
  39. package/dist/channel/ipc-timeouts.js.map +1 -0
  40. package/dist/channel/mcp-server.js +30 -14
  41. package/dist/channel/mcp-server.js.map +1 -1
  42. package/dist/channel/mcp-tools.js +28 -1
  43. package/dist/channel/mcp-tools.js.map +1 -1
  44. package/dist/channel/message-queue.d.ts +1 -0
  45. package/dist/channel/message-queue.js +49 -17
  46. package/dist/channel/message-queue.js.map +1 -1
  47. package/dist/channel/reconnect-backoff.d.ts +17 -0
  48. package/dist/channel/reconnect-backoff.js +21 -0
  49. package/dist/channel/reconnect-backoff.js.map +1 -0
  50. package/dist/channel/types.d.ts +29 -0
  51. package/dist/classic-channel-manager.js +1 -5
  52. package/dist/classic-channel-manager.js.map +1 -1
  53. package/dist/cli.js +148 -60
  54. package/dist/cli.js.map +1 -1
  55. package/dist/completion.d.ts +27 -0
  56. package/dist/completion.js +121 -0
  57. package/dist/completion.js.map +1 -0
  58. package/dist/config-validator.js +27 -0
  59. package/dist/config-validator.js.map +1 -1
  60. package/dist/config.js +6 -0
  61. package/dist/config.js.map +1 -1
  62. package/dist/cost-guard.d.ts +3 -1
  63. package/dist/cost-guard.js +3 -1
  64. package/dist/cost-guard.js.map +1 -1
  65. package/dist/daemon.d.ts +230 -13
  66. package/dist/daemon.js +975 -335
  67. package/dist/daemon.js.map +1 -1
  68. package/dist/event-log.d.ts +31 -0
  69. package/dist/event-log.js +96 -0
  70. package/dist/event-log.js.map +1 -1
  71. package/dist/fleet-context.d.ts +2 -0
  72. package/dist/fleet-manager.d.ts +209 -4
  73. package/dist/fleet-manager.js +813 -84
  74. package/dist/fleet-manager.js.map +1 -1
  75. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +22 -0
  76. package/dist/general-knowledge/skills/model-discovery/SKILL.md +14 -23
  77. package/dist/general-knowledge/skills/session-management/SKILL.md +15 -34
  78. package/dist/hang-detector.d.ts +19 -13
  79. package/dist/hang-detector.js +19 -49
  80. package/dist/hang-detector.js.map +1 -1
  81. package/dist/instance-lifecycle.d.ts +21 -1
  82. package/dist/instance-lifecycle.js +141 -7
  83. package/dist/instance-lifecycle.js.map +1 -1
  84. package/dist/instructions.d.ts +5 -0
  85. package/dist/instructions.js +10 -5
  86. package/dist/instructions.js.map +1 -1
  87. package/dist/locale.js +3 -0
  88. package/dist/locale.js.map +1 -1
  89. package/dist/logger.js +14 -0
  90. package/dist/logger.js.map +1 -1
  91. package/dist/mcp-liveness.d.ts +21 -0
  92. package/dist/mcp-liveness.js +27 -0
  93. package/dist/mcp-liveness.js.map +1 -0
  94. package/dist/outbound-handlers.d.ts +16 -0
  95. package/dist/outbound-handlers.js +121 -27
  96. package/dist/outbound-handlers.js.map +1 -1
  97. package/dist/outbound-schemas.d.ts +7 -2
  98. package/dist/outbound-schemas.js +13 -2
  99. package/dist/outbound-schemas.js.map +1 -1
  100. package/dist/pane-write-lock.d.ts +48 -0
  101. package/dist/pane-write-lock.js +73 -0
  102. package/dist/pane-write-lock.js.map +1 -0
  103. package/dist/process-memory.d.ts +31 -0
  104. package/dist/process-memory.js +79 -0
  105. package/dist/process-memory.js.map +1 -0
  106. package/dist/quickstart.js +17 -16
  107. package/dist/quickstart.js.map +1 -1
  108. package/dist/scheduler/db.js +3 -0
  109. package/dist/scheduler/db.js.map +1 -1
  110. package/dist/sd-notify.d.ts +45 -0
  111. package/dist/sd-notify.js +74 -3
  112. package/dist/sd-notify.js.map +1 -1
  113. package/dist/secret-file.d.ts +33 -0
  114. package/dist/secret-file.js +36 -0
  115. package/dist/secret-file.js.map +1 -0
  116. package/dist/setup-wizard.js +9 -7
  117. package/dist/setup-wizard.js.map +1 -1
  118. package/dist/tmux-control.d.ts +58 -5
  119. package/dist/tmux-control.js +107 -13
  120. package/dist/tmux-control.js.map +1 -1
  121. package/dist/tmux-manager.d.ts +47 -2
  122. package/dist/tmux-manager.js +103 -8
  123. package/dist/tmux-manager.js.map +1 -1
  124. package/dist/topic-commands.d.ts +43 -1
  125. package/dist/topic-commands.js +196 -66
  126. package/dist/topic-commands.js.map +1 -1
  127. package/dist/tui-glyphs.d.ts +25 -0
  128. package/dist/tui-glyphs.js +26 -0
  129. package/dist/tui-glyphs.js.map +1 -0
  130. package/dist/types.d.ts +19 -0
  131. package/dist/ui/view.html +291 -33
  132. package/dist/update-check.d.ts +22 -0
  133. package/dist/update-check.js +44 -0
  134. package/dist/update-check.js.map +1 -0
  135. package/dist/usage/providers.d.ts +50 -0
  136. package/dist/usage/providers.js +611 -0
  137. package/dist/usage/providers.js.map +1 -0
  138. package/dist/usage/usage-api.d.ts +50 -0
  139. package/dist/usage/usage-api.js +111 -0
  140. package/dist/usage/usage-api.js.map +1 -0
  141. package/dist/view-api.d.ts +10 -0
  142. package/dist/view-api.js +67 -14
  143. package/dist/view-api.js.map +1 -1
  144. package/dist/web-api.js +5 -2
  145. package/dist/web-api.js.map +1 -1
  146. package/package.json +4 -1
  147. package/templates/systemd.service.ejs +9 -1
  148. package/dist/channel/tool-tracker.d.ts +0 -13
  149. package/dist/channel/tool-tracker.js +0 -58
  150. package/dist/channel/tool-tracker.js.map +0 -1
  151. package/dist/daemon-entry.d.ts +0 -1
  152. package/dist/daemon-entry.js +0 -30
  153. package/dist/daemon-entry.js.map +0 -1
  154. package/dist/fleet-system-prompt.d.ts +0 -11
  155. package/dist/fleet-system-prompt.js +0 -61
  156. package/dist/fleet-system-prompt.js.map +0 -1
@@ -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 { sdNotify } from "./sd-notify.js";
7
+ import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
8
+ import { readFleetMemory } from "./process-memory.js";
8
9
  import { isScalar, parseDocument } from "yaml";
9
10
  const __filename = fileURLToPath(import.meta.url);
10
11
  const __dirname = dirname(__filename);
@@ -21,12 +22,12 @@ import { IpcClient } from "./channel/ipc-bridge.js";
21
22
  import { createAdapter } from "./channel/factory.js";
22
23
  import { createBackend } from "./backend/factory.js";
23
24
  import { isModelCompatible } from "./backend/types.js";
24
- import { createLogger } from "./logger.js";
25
+ import { createLogger, rotateLogIfNeeded } from "./logger.js";
25
26
  import { processAttachments } from "./channel/attachment-handler.js";
26
27
  import { routeToolCall } from "./channel/tool-router.js";
27
28
  import { Scheduler } from "./scheduler/index.js";
28
29
  import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
29
- import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG } from "./topic-commands.js";
30
+ import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, SAVE_FILENAME_RE, SAVE_UNSUPPORTED_MSG, resolveInstanceContext, forgetInstanceContext } from "./topic-commands.js";
30
31
  import { DailySummary } from "./daily-summary.js";
31
32
  import { WebhookEmitter } from "./webhook-emitter.js";
32
33
  import { TmuxControlClient } from "./tmux-control.js";
@@ -38,6 +39,7 @@ import { StatuslineWatcher } from "./statusline-watcher.js";
38
39
  import { outboundHandlers } from "./outbound-handlers.js";
39
40
  import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
40
41
  import { handleViewRequest, isViewPath } from "./view-api.js";
42
+ import { handleUsageRequest, isUsagePath } from "./usage/usage-api.js";
41
43
  import { handleSettingsRequest } from "./settings-api.js";
42
44
  import { setLocale, detectLocale, t } from "./locale.js";
43
45
  import { handleAgentRequest } from "./agent-endpoint.js";
@@ -83,6 +85,37 @@ const CANCEL_BTN_MAX_RETRIES = 3;
83
85
  * buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
84
86
  * reply). 5min (not the old 2s idle-watch) so Thinking isn't misread as idle. */
85
87
  const CANCEL_BTN_IDLE_CHECK_INTERVAL_MS = 5 * 60_000;
88
+ /**
89
+ * How often the cancel button's text is refreshed with elapsed working time.
90
+ *
91
+ * One edit per working instance per interval — at 60s that is trivial for both
92
+ * platforms' rate limits, and it reads as a live counter rather than a stale
93
+ * snapshot. Nothing new is posted, so the channel is never spammed: there is
94
+ * exactly one progress message per turn, and it is the cancel button itself.
95
+ */
96
+ const PROGRESS_UPDATE_INTERVAL_MS = 60_000;
97
+ /** Elapsed time is only shown once work has clearly outlasted a quick answer. */
98
+ const PROGRESS_MIN_ELAPSED_MS = 2 * 60_000;
99
+ /** How much of a tool summary the progress line will show before eliding. */
100
+ const PROGRESS_ACTIVITY_MAX_CHARS = 48;
101
+ /**
102
+ * Emoji AgEnD itself stamps on messages as machine signals: the delivery-status
103
+ * ladder (⏳ queued, 👀 delivered, ✅ confirmed, ❌ failed) and the 🫡 ack. They
104
+ * are indicators, not opinions, so they never belong in the reactions queue —
105
+ * regardless of who they arrive from, because the adapter-level bot filter can
106
+ * miss (Discord partial users may not carry the `bot` flag). The cost is that a
107
+ * human genuinely reacting ✅ is also dropped; with four bot-stamped meanings
108
+ * already attached to these glyphs, that reading is ambiguous anyway.
109
+ */
110
+ const DELIVERY_STATUS_EMOJIS = new Set(["👀", "⏳", "✅", "❌", "🫡"]);
111
+ /**
112
+ * How long a delivery waits out a disconnected instance IPC before giving up.
113
+ *
114
+ * Sized for a daemon restart (socket close → respawn → CLI ready), which is the
115
+ * event this exists for. Past it the delivery fails loudly as it always did.
116
+ */
117
+ const IPC_RECONNECT_GRACE_MS = 30_000;
118
+ const IPC_RECONNECT_POLL_MS = 250;
86
119
  const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
87
120
  const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
88
121
  const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
@@ -93,7 +126,7 @@ export class FleetManager {
93
126
  static sighupHandlerInstalled = false;
94
127
  children = new Map();
95
128
  lifecycle;
96
- /** @deprecated Use lifecycle.daemons — kept for backward compat */
129
+ /** Live view of lifecycle.daemons — used throughout; not deprecated. */
97
130
  get daemons() { return this.lifecycle.daemons; }
98
131
  fleetConfig = null;
99
132
  rawFleetConfig = {};
@@ -151,6 +184,10 @@ export class FleetManager {
151
184
  // reply, on cancel, or when a newer button supersedes it for the same
152
185
  // instance. Per-button tracking means a failed delete never strands a button.
153
186
  cancelButtons = new Map();
187
+ /** instanceName → what it is doing right now, when the backend can tell us. */
188
+ instanceActivity = new Map();
189
+ /** instanceName → tail of deliveries waiting for its IPC to come back. */
190
+ ipcWaitTails = new Map();
154
191
  // Last user message delivered to each instance — used to react ✅ on completion.
155
192
  lastInboundMsg = new Map();
156
193
  topicArchiver;
@@ -163,6 +200,10 @@ export class FleetManager {
163
200
  failoverActive = new Map(); // instance → current failover model
164
201
  // IPC reconnect: tracks instances being intentionally stopped (skip reconnect)
165
202
  ipcStoppingInstances = new Set();
203
+ /** Coalesce concurrent connection attempts for the same daemon socket. */
204
+ ipcConnectInFlight = new Map();
205
+ /** At most one reconnect/backoff loop may exist per instance. */
206
+ ipcReconnectInFlight = new Map();
166
207
  // Adapter restart: prevents re-entrant restart attempts
167
208
  adapterRestarting = new Set();
168
209
  // Adapter isolation: track state per adapter for retry + visibility
@@ -172,6 +213,10 @@ export class FleetManager {
172
213
  healthServer = null;
173
214
  healthPortRetried = false;
174
215
  updateCheckTimer = null;
216
+ eventLogPruneTimer = null;
217
+ logRotateTimer = null;
218
+ /** Days of event/activity history to keep. */
219
+ static EVENT_LOG_RETENTION_DAYS = 30;
175
220
  watchdogTimer = null;
176
221
  startedAt = 0;
177
222
  // Mirror topic: buffer cross-instance messages, flush every 3s
@@ -210,7 +255,13 @@ export class FleetManager {
210
255
  }
211
256
  this.reloadPending = false;
212
257
  this.reconcileInFlight = this.reconcileInstances()
213
- .catch(err => this.logger.error({ err }, "SIGHUP config reload failed"))
258
+ .catch(err => {
259
+ // Almost always a YAML parse error. Log-only meant the user edited
260
+ // fleet.yaml, sent SIGHUP, and got no reaction and no explanation.
261
+ this.logger.error({ err }, "SIGHUP config reload failed");
262
+ const message = err instanceof Error ? err.message : String(err);
263
+ this.notifyFleetError(`⚠️ fleet.yaml reload FAILED — ${message}\nThe previous configuration is still running.`);
264
+ })
214
265
  .finally(() => {
215
266
  this.reconcileInFlight = null;
216
267
  if (this.reloadPending && this.startupComplete) {
@@ -473,8 +524,12 @@ export class FleetManager {
473
524
  // warm_cap: a fresh transition into idle may free this instance for eviction,
474
525
  // or (more usefully) reveal that the fleet is now over cap. Only fire on the
475
526
  // edge into idle, not on every idle heartbeat.
476
- if (state === "idle" && previous?.state !== "idle")
527
+ if (state === "idle" && previous?.state !== "idle") {
477
528
  this.enforceWarmCap();
529
+ // The turn is genuinely over — retire the cancel/progress button now rather
530
+ // than waiting for the 5-minute idle backstop to notice.
531
+ this.retireInstanceButtons(name);
532
+ }
478
533
  }
479
534
  cacheInstanceProcessStatus(name, status) {
480
535
  if (status === "running") {
@@ -579,12 +634,70 @@ export class FleetManager {
579
634
  if (!idle) {
580
635
  this.logger.warn({ instanceName, timeoutMs }, "Idle gate timed out; forcing delivery");
581
636
  }
582
- const ipc = this.instanceIpcClients.get(instanceName);
583
- if (!ipc?.connected)
584
- throw new Error(`Instance '${instanceName}' IPC is unavailable`);
585
- ipc.send(payload);
637
+ await this.sendWhenConnected(instanceName, payload);
586
638
  this.lastDeliveryAt.set(instanceName, Date.now());
587
639
  }
640
+ /**
641
+ * Hand a payload to an instance's IPC, waiting out a *transient* disconnect.
642
+ *
643
+ * A daemon that is restarting — `/restart`, crash recovery, a model switch —
644
+ * drops its socket for a few seconds. Any message arriving in that window used
645
+ * to fail instantly: the caller logged a warning, put ❌ on the user's message,
646
+ * and the message was gone. The user had to notice the ❌ and retype it. That is
647
+ * the "instance 訊息不容易掉" goal failing on the most predictable event there is.
648
+ *
649
+ * The wait is bounded. If the instance is genuinely down, this still throws and
650
+ * the ❌ still appears — just for a real failure rather than a restart.
651
+ *
652
+ * Ordering is preserved by serialising behind any waiter already queued for this
653
+ * instance, *including* when the socket happens to be up: otherwise a message
654
+ * arriving after the reconnect could overtake one that has been waiting for it.
655
+ */
656
+ async sendWhenConnected(instanceName, payload) {
657
+ const queued = this.ipcWaitTails.get(instanceName);
658
+ if (!queued) {
659
+ const ipc = this.instanceIpcClients.get(instanceName);
660
+ if (ipc?.connected && ipc.send(payload))
661
+ return;
662
+ }
663
+ const attempt = (queued ?? Promise.resolve())
664
+ .catch(() => { })
665
+ .then(() => this.sendAfterIpcReturns(instanceName, payload));
666
+ // The chain stores a settled-either-way promise so one failed delivery cannot
667
+ // wedge every later one, and so `queued` above is safe to await unguarded.
668
+ const tail = attempt.catch(() => { });
669
+ this.ipcWaitTails.set(instanceName, tail);
670
+ try {
671
+ await attempt;
672
+ }
673
+ finally {
674
+ // Only the last waiter clears the chain; while a queue is still draining the
675
+ // map must keep pointing at it or ordering is lost.
676
+ if (this.ipcWaitTails.get(instanceName) === tail) {
677
+ this.ipcWaitTails.delete(instanceName);
678
+ }
679
+ }
680
+ }
681
+ /** Poll for the instance's IPC to come back, then send. Throws if it does not. */
682
+ async sendAfterIpcReturns(instanceName, payload) {
683
+ const deadline = Date.now() + IPC_RECONNECT_GRACE_MS;
684
+ let warned = false;
685
+ for (;;) {
686
+ // Re-read every round: a reconnect replaces the IpcClient object entirely,
687
+ // so a cached reference would stay dead forever.
688
+ const ipc = this.instanceIpcClients.get(instanceName);
689
+ if (ipc?.connected && ipc.send(payload))
690
+ return;
691
+ if (Date.now() >= deadline) {
692
+ throw new Error(`Instance '${instanceName}' IPC is unavailable`);
693
+ }
694
+ if (!warned) {
695
+ warned = true;
696
+ this.logger.info({ instanceName }, "Instance IPC is down — holding delivery until it reconnects");
697
+ }
698
+ await new Promise(resolve => setTimeout(resolve, IPC_RECONNECT_POLL_MS));
699
+ }
700
+ }
588
701
  /** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
589
702
  async deliverToInstance(instanceName, payload, options = {}) {
590
703
  const meta = payload.meta && typeof payload.meta === "object"
@@ -600,10 +713,7 @@ export class FleetManager {
600
713
  await this.lifecycle.wake(instanceName, 30_000);
601
714
  this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
602
715
  }
603
- const ipc = this.instanceIpcClients.get(instanceName);
604
- if (!ipc?.connected)
605
- throw new Error(`Instance '${instanceName}' IPC is unavailable`);
606
- ipc.send(payload);
716
+ await this.sendWhenConnected(instanceName, payload);
607
717
  // A cross-instance item arriving before the daemon observes this turn as
608
718
  // working must not trust the stale idle snapshot from before the send.
609
719
  this.lastDeliveryAt.set(instanceName, Date.now());
@@ -651,7 +761,7 @@ export class FleetManager {
651
761
  await new Promise(resolve => setTimeout(resolve, 250));
652
762
  await this.startClassicInstance(instanceName, this.classicChannels.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channel.channelId, channel.adapterId), this.classicChannels.getModel(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
653
763
  }
654
- async startInstance(name, config, topicMode) {
764
+ async startInstance(name, config, topicMode, kind = "fleet-topic") {
655
765
  if (this.lifecycle.isPaused(name)) {
656
766
  this.logger.info({ name }, "Persisted paused instance — skipping startup");
657
767
  return;
@@ -667,7 +777,11 @@ export class FleetManager {
667
777
  this.ensureGeneralInstructions(config.working_directory, config.backend);
668
778
  }
669
779
  this.instanceProcessStatus.delete(name);
670
- await this.lifecycle.start(name, config, topicMode);
780
+ await this.lifecycle.start(name, config, topicMode, {
781
+ kind,
782
+ backend: config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
783
+ model: this.resolveInstanceModel(name).display,
784
+ });
671
785
  // Auto-connect IPC — daemon.start() ensures socket is ready before resolving
672
786
  await this.connectIpcToInstance(name);
673
787
  }
@@ -845,7 +959,6 @@ export class FleetManager {
845
959
  this.configPath = configPath;
846
960
  this.loadEnvFile();
847
961
  // Rotate fleet.log if oversized (before any logging)
848
- const { rotateLogIfNeeded } = await import("./logger.js");
849
962
  rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
850
963
  const fleet = this.loadConfig(configPath);
851
964
  setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
@@ -889,7 +1002,7 @@ export class FleetManager {
889
1002
  }
890
1003
  const pidPath = join(this.dataDir, "fleet.pid");
891
1004
  writeFileSync(pidPath, String(process.pid), "utf-8");
892
- this.eventLog = new EventLog(join(this.dataDir, "events.db"));
1005
+ this.eventLog = this.openEventLog();
893
1006
  // Initialize classic channel manager. The primary adapter (channels[0])
894
1007
  // migrates legacy single-bot entries and names without a suffix. Classic
895
1008
  // routing does NOT go through the routing engine (single-key, can't hold two
@@ -978,6 +1091,10 @@ export class FleetManager {
978
1091
  // Rotate fleet.log daily too (besides the startup size check above), so a
979
1092
  // long-running fleet doesn't accumulate an unbounded log.
980
1093
  rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
1094
+ // Instance output.log is pipe-pane (TUI ANSI). Daemon health ticks rotate a
1095
+ // running instance's own log; this sweep is the safety net for every other
1096
+ // kind. One implementation, so the two cannot cover different sets.
1097
+ this.rotateAllInstanceLogs();
981
1098
  }, () => {
982
1099
  const instances = Object.keys(this.fleetConfig?.instances ?? {});
983
1100
  const costMap = new Map();
@@ -1117,9 +1234,29 @@ export class FleetManager {
1117
1234
  }
1118
1235
  }
1119
1236
  }
1120
- // Signal systemd: generals ready
1121
- sdNotify("READY=1");
1237
+ // The systemd watchdog answers exactly one question: is this process still
1238
+ // turning its event loop? Pinging from a timer proves that, and after the
1239
+ // blocking child-process calls were made async it is a meaningful signal —
1240
+ // a deadlocked or frozen fleet stops pinging and systemd restarts it.
1241
+ //
1242
+ // It deliberately does NOT gate on fleet health. "No adapter connected" or
1243
+ // "an instance crashed" must not kill the process: the fleet would be restarted
1244
+ // into the same broken state, and a user who has legitimately stopped every
1245
+ // instance would get a restart loop. Those conditions surface through /health
1246
+ // (which now returns 503) and through the General-topic notifications instead.
1122
1247
  this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
1248
+ // EventLog.prune() existed but was never called, so `events` and `activity`
1249
+ // grew without bound for the life of the install. Prune once at startup and
1250
+ // daily after that; the timer is unref'd so it never holds the loop open.
1251
+ this.pruneEventLog();
1252
+ this.eventLogPruneTimer = setInterval(() => this.pruneEventLog(), 24 * 60 * 60_000);
1253
+ this.eventLogPruneTimer.unref?.();
1254
+ // Same shape for pipe-pane logs, and for the same reason: the only sweep that
1255
+ // covered them lived inside the daily-summary callback, so it did not run at
1256
+ // all when summaries were off.
1257
+ this.rotateAllInstanceLogs();
1258
+ this.logRotateTimer = setInterval(() => this.rotateAllInstanceLogs(), 24 * 60 * 60_000);
1259
+ this.logRotateTimer.unref?.();
1123
1260
  // Phase 2: Start remaining instances with staggered concurrency
1124
1261
  if (others.length > 0) {
1125
1262
  await this.startInstancesWithConcurrency(others, topicMode);
@@ -1255,6 +1392,15 @@ export class FleetManager {
1255
1392
  // rest of startup finishes. Replay one coalesced reload only after all
1256
1393
  // startup-owned lifecycle work and signal handlers are in place.
1257
1394
  this.finishStartup();
1395
+ // Tell systemd we are ready only now. This used to fire right after the
1396
+ // generals started — before adapters, classic instances, topic creation and the
1397
+ // health server — so `systemctl start` returned success while the fleet was
1398
+ // still deaf: no path existed for a user message to arrive.
1399
+ sdNotify("READY=1");
1400
+ const health = this.getFleetHealth();
1401
+ if (health.status !== "ok") {
1402
+ this.logger.warn({ health }, "Fleet started with problems — see /health");
1403
+ }
1258
1404
  }
1259
1405
  /**
1260
1406
  * Delete inbox files older than retentionDays (by mtime). Cleans the shared
@@ -1371,6 +1517,67 @@ export class FleetManager {
1371
1517
  getAdapterStates() {
1372
1518
  return this.adapterState;
1373
1519
  }
1520
+ /**
1521
+ * Real, checkable fleet health for `/health` and the operator.
1522
+ *
1523
+ * `status` is:
1524
+ * - `ok` — at least one adapter connected and every configured instance
1525
+ * that should be running is running
1526
+ * - `degraded` — reachable, but something the operator should look at (an
1527
+ * adapter retrying, an instance crashed or stopped)
1528
+ * - `down` — the fleet cannot do its job: no adapter is connected, so no
1529
+ * message can arrive or be answered
1530
+ *
1531
+ * Deliberately does NOT gate the systemd watchdog — see the comment at the
1532
+ * WATCHDOG timer for why.
1533
+ */
1534
+ getFleetHealth() {
1535
+ const names = Object.keys(this.fleetConfig?.instances ?? {});
1536
+ const counts = { configured: names.length, running: 0, crashed: 0, paused: 0, stopped: 0 };
1537
+ for (const name of names) {
1538
+ const state = this.getInstanceStatus(name);
1539
+ if (state === "running")
1540
+ counts.running++;
1541
+ else if (state === "crashed")
1542
+ counts.crashed++;
1543
+ else if (state === "paused")
1544
+ counts.paused++;
1545
+ else
1546
+ counts.stopped++;
1547
+ }
1548
+ const states = {};
1549
+ let connected = 0;
1550
+ for (const [id, state] of this.adapterState) {
1551
+ states[id] = state.status;
1552
+ if (state.status === "connected")
1553
+ connected++;
1554
+ }
1555
+ const problems = [];
1556
+ if (this.adapterState.size > 0 && connected === 0)
1557
+ problems.push("no channel adapter is connected");
1558
+ if (counts.crashed > 0)
1559
+ problems.push(`${counts.crashed} instance(s) crashed`);
1560
+ for (const [id, state] of this.adapterState) {
1561
+ if (state.status !== "connected")
1562
+ problems.push(`adapter ${id} is ${state.status}`);
1563
+ }
1564
+ if (!this.startupComplete)
1565
+ problems.push("startup has not completed");
1566
+ // "down" is reserved for "cannot receive or answer a message at all". A fleet
1567
+ // with adapters configured but none connected is exactly that.
1568
+ const status = this.adapterState.size > 0 && connected === 0
1569
+ ? "down"
1570
+ : problems.length > 0 ? "degraded" : "ok";
1571
+ return {
1572
+ status,
1573
+ uptime: Math.floor((Date.now() - this.startedAt) / 1000),
1574
+ instances: counts,
1575
+ adapters: { total: this.adapterState.size, connected, states },
1576
+ startupComplete: this.startupComplete,
1577
+ memory: readFleetMemory(),
1578
+ problems,
1579
+ };
1580
+ }
1374
1581
  /** Start the primary adapter (backward-compatible, sets this.adapter) */
1375
1582
  async startSingleAdapter(fleet, channelConfig) {
1376
1583
  const botToken = process.env[channelConfig.bot_token_env];
@@ -1397,6 +1604,9 @@ export class FleetManager {
1397
1604
  this.adapter.on("message", safeHandler(async (msg) => {
1398
1605
  await this.handleInboundMessage(msg);
1399
1606
  }, this.logger, "adapter.message"));
1607
+ this.adapter.on("reaction", safeHandler(async (r) => {
1608
+ await this.handleInboundReaction(r);
1609
+ }, this.logger, "adapter.reaction"));
1400
1610
  this.adapter.on("callback_query", safeHandler(async (data) => {
1401
1611
  if (await this.handleClassicBackendSelection(data))
1402
1612
  return;
@@ -1571,16 +1781,21 @@ export class FleetManager {
1571
1781
  await data.respond(t("not_authorized"));
1572
1782
  return;
1573
1783
  }
1784
+ await data.respond(await this.runBackendDoctor());
1785
+ }
1786
+ else if (data.command === "usage") {
1787
+ // Admin-gated like /doctor: the metrics describe the operator's accounts
1788
+ // (plans, spend caps), and the reply is ephemeral so only the caller sees it.
1789
+ if (!this.isFleetAdmin(data.userId, adapterId)) {
1790
+ await data.respond(t("not_authorized"));
1791
+ return;
1792
+ }
1574
1793
  try {
1575
- const { execSync } = await import("node:child_process");
1576
- const backend = this.fleetConfig?.defaults?.backend || "claude-code";
1577
- const result = execSync(`agend backend doctor ${backend}`, { timeout: 30_000, encoding: "utf-8" });
1578
- const clean = result.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
1579
- await data.respond(clean || "No output");
1794
+ const { getUsageSnapshot, formatUsageSummary } = await import("./usage/usage-api.js");
1795
+ await data.respond(formatUsageSummary(await getUsageSnapshot()));
1580
1796
  }
1581
1797
  catch (err) {
1582
- const output = (err.stdout ?? err.message ?? "Doctor failed").replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
1583
- await data.respond(output);
1798
+ await data.respond(`⚠️ Usage fetch failed: ${err.message}`);
1584
1799
  }
1585
1800
  }
1586
1801
  else if (data.command === "status") {
@@ -1692,6 +1907,9 @@ export class FleetManager {
1692
1907
  adapter.on("message", safeHandler(async (msg) => {
1693
1908
  await this.handleInboundMessage(msg);
1694
1909
  }, this.logger, `adapter[${adapterId}].message`));
1910
+ adapter.on("reaction", safeHandler(async (r) => {
1911
+ await this.handleInboundReaction(r);
1912
+ }, this.logger, `adapter[${adapterId}].reaction`));
1695
1913
  adapter.on("callback_query", safeHandler(async (data) => {
1696
1914
  if (await this.handleClassicBackendSelection(data))
1697
1915
  return;
@@ -1853,16 +2071,21 @@ export class FleetManager {
1853
2071
  await data.respond(t("not_authorized"));
1854
2072
  return;
1855
2073
  }
2074
+ await data.respond(await this.runBackendDoctor());
2075
+ }
2076
+ else if (data.command === "usage") {
2077
+ // Admin-gated like /doctor: the metrics describe the operator's accounts
2078
+ // (plans, spend caps), and the reply is ephemeral so only the caller sees it.
2079
+ if (!this.isFleetAdmin(data.userId, adapterId)) {
2080
+ await data.respond(t("not_authorized"));
2081
+ return;
2082
+ }
1856
2083
  try {
1857
- const { execSync } = await import("node:child_process");
1858
- const backend = this.fleetConfig?.defaults?.backend || "claude-code";
1859
- const result = execSync(`agend backend doctor ${backend}`, { timeout: 30_000, encoding: "utf-8" });
1860
- const clean = result.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
1861
- await data.respond(clean || "No output");
2084
+ const { getUsageSnapshot, formatUsageSummary } = await import("./usage/usage-api.js");
2085
+ await data.respond(formatUsageSummary(await getUsageSnapshot()));
1862
2086
  }
1863
2087
  catch (err) {
1864
- const output = (err.stdout ?? err.message ?? "Doctor failed").replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
1865
- await data.respond(output);
2088
+ await data.respond(`⚠️ Usage fetch failed: ${err.message}`);
1866
2089
  }
1867
2090
  }
1868
2091
  else if (data.command === "status") {
@@ -1931,19 +2154,38 @@ export class FleetManager {
1931
2154
  this.logger.info({ adapterId, type: channelConfig.type }, "Additional adapter started");
1932
2155
  }
1933
2156
  /** Connect IPC to a single instance with all handlers */
1934
- async connectIpcToInstance(name) {
2157
+ connectIpcToInstance(name) {
2158
+ const inFlight = this.ipcConnectInFlight.get(name);
2159
+ if (inFlight)
2160
+ return inFlight;
2161
+ const connection = this.connectIpcToInstanceInternal(name)
2162
+ .finally(() => {
2163
+ if (this.ipcConnectInFlight.get(name) === connection) {
2164
+ this.ipcConnectInFlight.delete(name);
2165
+ }
2166
+ });
2167
+ this.ipcConnectInFlight.set(name, connection);
2168
+ return connection;
2169
+ }
2170
+ async connectIpcToInstanceInternal(name) {
1935
2171
  // Close existing client to prevent socket leak on reconnect
1936
2172
  const existing = this.instanceIpcClients.get(name);
1937
2173
  if (existing) {
1938
- this.ipcStoppingInstances.add(name);
2174
+ // Remove application listeners before destroying the socket. Even if a
2175
+ // future regression creates two clients, the replaced one cannot keep
2176
+ // handling fleet_outbound messages as an orphan.
2177
+ existing.removeAllListeners();
1939
2178
  try {
1940
- existing.close();
2179
+ await existing.close();
1941
2180
  }
1942
2181
  catch (err) {
1943
2182
  this.logger.debug({ err, name }, "IPC client close failed (likely already closed)");
1944
2183
  }
1945
- this.instanceIpcClients.delete(name);
1946
- this.ipcStoppingInstances.delete(name);
2184
+ finally {
2185
+ if (this.instanceIpcClients.get(name) === existing) {
2186
+ this.instanceIpcClients.delete(name);
2187
+ }
2188
+ }
1947
2189
  }
1948
2190
  const sockPath = join(this.getInstanceDir(name), "channel.sock");
1949
2191
  if (!existsSync(sockPath))
@@ -2002,6 +2244,9 @@ export class FleetManager {
2002
2244
  else if (msg.type === "instance_process_state") {
2003
2245
  this.cacheInstanceProcessStatus(name, msg.status);
2004
2246
  }
2247
+ else if (msg.type === "instance_activity") {
2248
+ this.cacheInstanceActivity(name, msg.activity);
2249
+ }
2005
2250
  else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
2006
2251
  this.cacheInstanceExecutionState(name, msg);
2007
2252
  if (msg.type === "instance_state_response") {
@@ -2021,6 +2266,10 @@ export class FleetManager {
2021
2266
  }
2022
2267
  // Auto-reconnect on disconnect (unless intentionally stopping)
2023
2268
  ipc.on("disconnect", () => {
2269
+ // A delayed event from a replaced/stale client must never delete the
2270
+ // current connection or start another reconnect loop.
2271
+ if (this.instanceIpcClients.get(name) !== ipc)
2272
+ return;
2024
2273
  this.instanceIpcClients.delete(name);
2025
2274
  if (this.ipcStoppingInstances.has(name))
2026
2275
  return;
@@ -2032,7 +2281,20 @@ export class FleetManager {
2032
2281
  }
2033
2282
  }
2034
2283
  /** Attempt IPC reconnection with exponential backoff */
2035
- async ipcReconnect(name) {
2284
+ ipcReconnect(name) {
2285
+ const inFlight = this.ipcReconnectInFlight.get(name);
2286
+ if (inFlight)
2287
+ return inFlight;
2288
+ const reconnect = this.runIpcReconnect(name)
2289
+ .finally(() => {
2290
+ if (this.ipcReconnectInFlight.get(name) === reconnect) {
2291
+ this.ipcReconnectInFlight.delete(name);
2292
+ }
2293
+ });
2294
+ this.ipcReconnectInFlight.set(name, reconnect);
2295
+ return reconnect;
2296
+ }
2297
+ async runIpcReconnect(name) {
2036
2298
  for (let attempt = 1;; attempt++) {
2037
2299
  if (this.ipcStoppingInstances.has(name) || !this.daemons.has(name))
2038
2300
  return;
@@ -2055,9 +2317,22 @@ export class FleetManager {
2055
2317
  if (existsSync(windowIdPath)) {
2056
2318
  const windowId = readFileSync(windowIdPath, "utf-8").trim();
2057
2319
  if (windowId) {
2320
+ // Async with an explicit timeout: this was execSync with NO timeout at
2321
+ // all, so a wedged tmux server blocked the whole fleet event loop
2322
+ // indefinitely — while we were here to diagnose a lost connection.
2323
+ // A timeout is also the correct signal: an unresponsive tmux server
2324
+ // means we cannot verify the pane, which is treated as dead (the same
2325
+ // conclusion the old code reached only by throwing).
2058
2326
  try {
2059
- const { execSync } = await import("node:child_process");
2060
- execSync(`tmux list-panes -t "${windowId}"`, { stdio: "ignore" });
2327
+ const { execFile } = await import("node:child_process");
2328
+ const { promisify } = await import("node:util");
2329
+ const { getTmuxSocketName } = await import("./paths.js");
2330
+ // Honour socket isolation: without -L this queried the user's default
2331
+ // tmux server instead of the fleet's, so under a custom AGEND_HOME the
2332
+ // check was meaningless (it reported every pane dead).
2333
+ const socket = getTmuxSocketName();
2334
+ const args = socket ? ["-L", socket, "list-panes", "-t", windowId] : ["list-panes", "-t", windowId];
2335
+ await promisify(execFile)("tmux", args, { timeout: 5_000 });
2061
2336
  }
2062
2337
  catch {
2063
2338
  // Pane dead — respawn
@@ -2078,6 +2353,12 @@ export class FleetManager {
2078
2353
  if (this.adapterRestarting.has(id))
2079
2354
  return;
2080
2355
  this.adapterRestarting.add(id);
2356
+ // Reflect reality in adapterState throughout. This loop used to leave the state
2357
+ // untouched, so getAdapterStates() — and therefore /health and the dashboard —
2358
+ // kept reporting "connected" for an adapter that had been down for hours. An
2359
+ // adapter's true status was simply not knowable from inside the process.
2360
+ const previous = this.adapterState.get(id);
2361
+ this.adapterState.set(id, { status: "retrying", retryCount: 0, lastError: previous?.lastError });
2081
2362
  try {
2082
2363
  for (let attempt = 1;; attempt++) {
2083
2364
  if (this.ipcStoppingInstances.has("__fleet_stopping__"))
@@ -2090,9 +2371,16 @@ export class FleetManager {
2090
2371
  await adapter.stop().catch(() => { });
2091
2372
  await adapter.start();
2092
2373
  this.logger.info({ id, attempt }, "Adapter restarted successfully");
2374
+ this.adapterState.set(id, { status: "connected", retryCount: 0 });
2093
2375
  return;
2094
2376
  }
2095
- catch { /* retry */ }
2377
+ catch (err) {
2378
+ this.adapterState.set(id, {
2379
+ status: "retrying",
2380
+ retryCount: attempt,
2381
+ lastError: err?.message ?? String(err),
2382
+ });
2383
+ }
2096
2384
  if (attempt % 10 === 0) {
2097
2385
  this.logger.warn({ id, attempt }, "Adapter restart still failing");
2098
2386
  }
@@ -2128,6 +2416,49 @@ export class FleetManager {
2128
2416
  }
2129
2417
  return generals[0];
2130
2418
  }
2419
+ /**
2420
+ * A user reacted to one of the bot's messages (#408).
2421
+ *
2422
+ * A reaction is context, not a message (#432, reworking #413): it never triggers
2423
+ * an agent turn and never wakes anything. It is queued in the event log and rides
2424
+ * into the instance's NEXT real message as one compact leading line —
2425
+ * `[Recent reactions: 👍×2 from hanhanv]` — after which it is marked consumed.
2426
+ * No pending reactions → no line → zero context spent, which is the common case.
2427
+ */
2428
+ async handleInboundReaction(r) {
2429
+ const instanceName = this.resolveSlashTarget(r.threadId ?? r.chatId, r.adapterId);
2430
+ if (!instanceName) {
2431
+ this.logger.debug({ emoji: r.emoji, chatId: r.chatId }, "Reaction in an unrouted channel — ignoring");
2432
+ return;
2433
+ }
2434
+ if (DELIVERY_STATUS_EMOJIS.has(r.emoji)) {
2435
+ this.logger.debug({ emoji: r.emoji, user: r.username }, "Ignoring delivery-status emoji as a reaction");
2436
+ return;
2437
+ }
2438
+ this.eventLog?.logActivity("reaction", r.username, `${r.emoji} ${r.action}`, instanceName);
2439
+ if (r.action === "add") {
2440
+ this.eventLog?.addReaction(instanceName, r.messageId, r.username, r.emoji);
2441
+ }
2442
+ else {
2443
+ // Withdrawn before anyone saw it → it never happened. See removeReaction.
2444
+ this.eventLog?.removeReaction(instanceName, r.messageId, r.username, r.emoji);
2445
+ }
2446
+ }
2447
+ /**
2448
+ * The queued-reaction summary for an instance's next real message, or {} when
2449
+ * nothing is pending (the common case must add zero context). The consume
2450
+ * callback is separate from the fetch so reactions are only marked once the
2451
+ * message actually went out — a failed delivery keeps them queued.
2452
+ */
2453
+ pendingReactionsMeta(instanceName) {
2454
+ const pending = this.eventLog?.pendingReactions(instanceName);
2455
+ if (!pending)
2456
+ return { meta: {}, consume: () => { } };
2457
+ return {
2458
+ meta: { pending_reactions: pending.summary },
2459
+ consume: () => this.eventLog?.markReactionsConsumed(instanceName, pending.maxId),
2460
+ };
2461
+ }
2131
2462
  async handleInboundMessage(msg) {
2132
2463
  const threadId = msg.threadId || undefined;
2133
2464
  this.logger.debug({ source: msg.source, chatId: msg.chatId, threadId, userId: msg.userId, isBotMessage: msg.isBotMessage, textLen: (msg.text ?? "").length, text: (msg.text ?? "").slice(0, 80) }, "handleInboundMessage entry");
@@ -2465,6 +2796,7 @@ export class FleetManager {
2465
2796
  }
2466
2797
  this.warnIfRateLimited(generalInstance, msg);
2467
2798
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
2799
+ const generalReactions = this.pendingReactionsMeta(generalInstance);
2468
2800
  try {
2469
2801
  await this.deliverToInstance(generalInstance, {
2470
2802
  type: "fleet_inbound",
@@ -2480,9 +2812,11 @@ export class FleetManager {
2480
2812
  adapter_id: msg.adapterId,
2481
2813
  source: msg.source,
2482
2814
  ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
2815
+ ...generalReactions.meta,
2483
2816
  ...extraMeta,
2484
2817
  },
2485
2818
  });
2819
+ generalReactions.consume();
2486
2820
  this.lastInboundUser.set(generalInstance, msg.username);
2487
2821
  this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
2488
2822
  this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), generalInstance);
@@ -2558,6 +2892,7 @@ export class FleetManager {
2558
2892
  this.setTopicIcon(instanceName, "blue");
2559
2893
  this.warnIfRateLimited(instanceName, msg);
2560
2894
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, instanceName);
2895
+ const reactions = this.pendingReactionsMeta(instanceName);
2561
2896
  try {
2562
2897
  await this.deliverToInstance(instanceName, {
2563
2898
  type: "fleet_inbound",
@@ -2573,9 +2908,13 @@ export class FleetManager {
2573
2908
  adapter_id: msg.adapterId,
2574
2909
  source: msg.source,
2575
2910
  ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
2911
+ ...reactions.meta,
2576
2912
  ...extraMeta,
2577
2913
  },
2578
2914
  });
2915
+ // Only after the message actually went out. A failed delivery keeps the
2916
+ // reactions queued for the retry / the next message.
2917
+ reactions.consume();
2579
2918
  }
2580
2919
  catch (err) {
2581
2920
  this.logger.warn({ err: err.message, instanceName }, "Wake/delivery failed");
@@ -2667,8 +3006,18 @@ export class FleetManager {
2667
3006
  // Route standard channel tools (reply, react, edit_message, download_attachment)
2668
3007
  if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
2669
3008
  if (tool === "reply") {
2670
- // Agent answered retire its pending cancel button and mark done.
2671
- this.clearCancelButton(instanceName);
3009
+ // A reply is NOT proof the turn is over: on multi-step work an agent
3010
+ // replies ("starting…") and keeps going for many minutes. Retiring the
3011
+ // button here left the channel looking idle with no way to cancel and no
3012
+ // sign anything was happening (#410). Idle state owns retirement now; if the
3013
+ // instance is still working, move the button below the new reply so it stays
3014
+ // the last thing in the channel.
3015
+ if (this.getInstanceIdle(instanceName)) {
3016
+ this.clearCancelButton(instanceName);
3017
+ }
3018
+ else {
3019
+ void this.sendCancelButton(instanceName);
3020
+ }
2672
3021
  this.reactDone(instanceName);
2673
3022
  const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
2674
3023
  this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
@@ -3299,6 +3648,9 @@ export class FleetManager {
3299
3648
  }
3300
3649
  }
3301
3650
  async removeInstance(name) {
3651
+ // Drop cached pane context — the map is keyed by instance name and nothing
3652
+ // else evicted deleted entries, so it grew for the life of the process.
3653
+ forgetInstanceContext(name);
3302
3654
  // Clean up schedules (scheduler is fleet-level, not lifecycle-level)
3303
3655
  const config = this.fleetConfig?.instances[name];
3304
3656
  if (this.scheduler && config?.topic_id) {
@@ -3404,6 +3756,171 @@ export class FleetManager {
3404
3756
  this.collabInstances.add(instanceName);
3405
3757
  return true;
3406
3758
  }
3759
+ /**
3760
+ * Open the event log, tolerating a corrupt file.
3761
+ *
3762
+ * `events.db` holds history only — event rows and the activity feed. Nothing the
3763
+ * fleet needs to run depends on it, and every consumer already uses
3764
+ * `this.eventLog?.`. An unguarded `new EventLog(...)` here meant a corrupt or
3765
+ * unreadable history file (a truncated WAL after a hard kill, a full disk)
3766
+ * threw during startAll and the WHOLE FLEET FAILED TO BOOT — trading every
3767
+ * running agent for a file whose only job is reporting.
3768
+ *
3769
+ * So: try, move a bad file aside and retry once with a fresh one, and if even
3770
+ * that fails carry on without an event log.
3771
+ */
3772
+ /**
3773
+ * Run `agend backend doctor` for the fleet's default backend and return its
3774
+ * cleaned output.
3775
+ *
3776
+ * Async on purpose: this was `execSync` with a 30s timeout, reachable by any
3777
+ * allowlisted user through `/doctor`. While it ran, the entire fleet event loop
3778
+ * was frozen — no IPC, no adapter, no message delivery, no health responses,
3779
+ * and critically no WATCHDOG ping, so a slow doctor could push past
3780
+ * WatchdogSec and have systemd SIGABRT the fleet.
3781
+ */
3782
+ async runBackendDoctor() {
3783
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
3784
+ const backend = this.fleetConfig?.defaults?.backend || "claude-code";
3785
+ try {
3786
+ const { execFile } = await import("node:child_process");
3787
+ const { promisify } = await import("node:util");
3788
+ // execFile with an argv array — no shell, so the backend name cannot be
3789
+ // interpreted as a command even if config is malformed.
3790
+ const { stdout } = await promisify(execFile)("agend", ["backend", "doctor", backend], {
3791
+ timeout: 30_000,
3792
+ encoding: "utf-8",
3793
+ });
3794
+ return stripAnsi(stdout) || "No output";
3795
+ }
3796
+ catch (err) {
3797
+ const e = err;
3798
+ return stripAnsi(e.stdout ?? e.message ?? "Doctor failed");
3799
+ }
3800
+ }
3801
+ /** Drop event/activity rows older than the retention window. Best-effort. */
3802
+ /**
3803
+ * Cap every instance's pipe-pane log, walking the instances **directory** rather
3804
+ * than the config.
3805
+ *
3806
+ * A running instance rotates its own log on each health tick, so the ones that
3807
+ * need this are the ones nothing else looks at:
3808
+ *
3809
+ * - deleted instances, whose directory outlives the config entry. Nothing ever
3810
+ * touched these again. On the machine this was found on, one held 122 MB and
3811
+ * another 74 MB, out of 622 MB of pipe-pane logs in total.
3812
+ * - classic instances, which live in classicChannels, not fleetConfig.instances,
3813
+ * and so were never in the old config-driven loop at all.
3814
+ * - stopped instances, which have no health tick running.
3815
+ *
3816
+ * pipe-pane writes raw TUI output, so a wedged splash screen can emit ANSI frames
3817
+ * at animation rate. Unbounded growth here fills the disk, which takes the whole
3818
+ * fleet down rather than one instance.
3819
+ */
3820
+ rotateAllInstanceLogs() {
3821
+ const root = join(this.dataDir, "instances");
3822
+ let entries;
3823
+ try {
3824
+ entries = readdirSync(root, { withFileTypes: true });
3825
+ }
3826
+ catch {
3827
+ return; // no instances directory yet
3828
+ }
3829
+ for (const entry of entries) {
3830
+ if (!entry.isDirectory())
3831
+ continue;
3832
+ // rotateLogIfNeeded is already best-effort and returns early on a missing
3833
+ // file, so a directory without a pipe-pane log costs one stat.
3834
+ rotateLogIfNeeded(join(root, entry.name, "output.log"));
3835
+ }
3836
+ }
3837
+ pruneEventLog() {
3838
+ try {
3839
+ this.eventLog?.prune(FleetManager.EVENT_LOG_RETENTION_DAYS);
3840
+ }
3841
+ catch (err) {
3842
+ this.logger.warn({ err }, "Event log prune failed");
3843
+ }
3844
+ }
3845
+ openEventLog() {
3846
+ const dbPath = join(this.dataDir, "events.db");
3847
+ try {
3848
+ return new EventLog(dbPath);
3849
+ }
3850
+ catch (err) {
3851
+ this.logger.error({ err, dbPath }, "events.db unusable — moving it aside and starting a fresh one");
3852
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
3853
+ for (const suffix of ["", "-wal", "-shm"]) {
3854
+ try {
3855
+ renameSync(`${dbPath}${suffix}`, `${dbPath}${suffix}.corrupt-${stamp}`);
3856
+ }
3857
+ catch { /* may not exist */ }
3858
+ }
3859
+ try {
3860
+ return new EventLog(dbPath);
3861
+ }
3862
+ catch (retryErr) {
3863
+ // History is worth losing; a fleet that won't start is not.
3864
+ this.logger.error({ err: retryErr, dbPath }, "Could not open a fresh events.db — continuing without event logging");
3865
+ return null;
3866
+ }
3867
+ }
3868
+ }
3869
+ /**
3870
+ * Report a fleet-level fault (not attributable to one instance) to the General
3871
+ * topic, so the operator learns about it without reading daemon.log.
3872
+ *
3873
+ * Throttled per distinct message: an unhandled rejection typically comes from a
3874
+ * loop (a poller, a repeating timer), and one channel message per occurrence
3875
+ * would bury the topic — which is worse than silence. First occurrence goes out
3876
+ * immediately, repeats are suppressed for THROTTLE_MS and then re-sent with a
3877
+ * count.
3878
+ *
3879
+ * The log line is written by the caller regardless: if every adapter is down,
3880
+ * the only notification path is the one that is broken.
3881
+ */
3882
+ notifyFleetError(text) {
3883
+ const now = Date.now();
3884
+ const key = text.slice(0, 200);
3885
+ const seen = this.fleetErrorNotices.get(key);
3886
+ if (seen && now - seen.at < FleetManager.FLEET_ERROR_THROTTLE_MS) {
3887
+ seen.suppressed++;
3888
+ return;
3889
+ }
3890
+ const suppressed = seen?.suppressed ?? 0;
3891
+ this.fleetErrorNotices.set(key, { at: now, suppressed: 0 });
3892
+ // Bound the map: it is keyed by message text, and a message with a varying
3893
+ // suffix (a path, an id) would otherwise grow it without limit.
3894
+ if (this.fleetErrorNotices.size > 100) {
3895
+ const oldest = this.fleetErrorNotices.keys().next().value;
3896
+ if (oldest !== undefined)
3897
+ this.fleetErrorNotices.delete(oldest);
3898
+ }
3899
+ const body = suppressed > 0
3900
+ ? `${text}\n(plus ${suppressed} more in the last ${Math.round(FleetManager.FLEET_ERROR_THROTTLE_MS / 60_000)}m)`
3901
+ : text;
3902
+ // Resolved from config, NOT findGeneralInstance(): that requires a live daemon,
3903
+ // and a fleet-level fault is exactly when the General may be down. The topic
3904
+ // itself still exists, and notifyInstanceTopic only needs adapter + group +
3905
+ // topic_id to post into it.
3906
+ const general = Object.entries(this.fleetConfig?.instances ?? {})
3907
+ .find(([, config]) => config.general_topic === true)?.[0];
3908
+ if (general) {
3909
+ this.notifyInstanceTopic(general, body);
3910
+ return;
3911
+ }
3912
+ // No General instance — fall back to the primary channel's group.
3913
+ const channelCfg = this.getChannelConfig();
3914
+ const groupId = channelCfg?.group_id;
3915
+ if (this.adapter && groupId) {
3916
+ this.adapter.sendText(String(groupId), body)
3917
+ .catch(err => this.logger.warn({ err }, "Failed to send fleet error notification"));
3918
+ return;
3919
+ }
3920
+ this.logger.warn({ text: body }, "Fleet error had no notification target (no General instance, no adapter)");
3921
+ }
3922
+ static FLEET_ERROR_THROTTLE_MS = 10 * 60_000;
3923
+ fleetErrorNotices = new Map();
3407
3924
  notifyInstanceTopic(instanceName, text, extraOpts) {
3408
3925
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
3409
3926
  if (!adapter)
@@ -3533,7 +4050,12 @@ export class FleetManager {
3533
4050
  threadId: sent.threadId ?? threadId,
3534
4051
  correlationId,
3535
4052
  retryCount: 0,
4053
+ // Elapsed time is measured from when this button was posted — i.e. from
4054
+ // when the work was handed over — not from the pane's working transition,
4055
+ // which resets if the CLI blips idle mid-turn.
4056
+ startedAt: Date.now(),
3536
4057
  };
4058
+ this.startProgressTicker(entry);
3537
4059
  // Idle-check backstop: every 5min, if the instance is idle, retire the
3538
4060
  // button. Covers turns that end without hitting a clear trigger (reply /
3539
4061
  // cancel / correlation). Cleared in discardButton when the entry is removed.
@@ -3554,6 +4076,97 @@ export class FleetManager {
3554
4076
  this.logger.warn({ err: e.message, instanceName }, "Failed to send cancel button");
3555
4077
  }
3556
4078
  }
4079
+ /**
4080
+ * The cancel button's text for a given elapsed time.
4081
+ *
4082
+ * Below the threshold it keeps the original wording, so a normal quick answer
4083
+ * looks exactly as it did before. Past it, the button doubles as the live
4084
+ * progress indicator (#409) — the channel showed nothing at all during long work,
4085
+ * and once the agent had replied once there was no sign it was still going.
4086
+ */
4087
+ static progressText(elapsedMs, activity) {
4088
+ if (elapsedMs < PROGRESS_MIN_ELAPSED_MS)
4089
+ return "👀 處理中…";
4090
+ const totalSeconds = Math.floor(elapsedMs / 1000);
4091
+ const minutes = Math.floor(totalSeconds / 60);
4092
+ const seconds = totalSeconds % 60;
4093
+ const elapsed = minutes >= 60
4094
+ ? `${Math.floor(minutes / 60)}h ${minutes % 60}m`
4095
+ : `${minutes}m ${String(seconds).padStart(2, "0")}s`;
4096
+ const detail = FleetManager.sanitizeActivity(activity);
4097
+ return detail
4098
+ ? `⏳ 處理中… (已進行 ${elapsed} · ${detail})`
4099
+ : `⏳ 處理中… (已進行 ${elapsed})`;
4100
+ }
4101
+ /**
4102
+ * Make a tool summary safe to paste into a channel message.
4103
+ *
4104
+ * The text is agent-controlled (it is built from tool inputs — file paths,
4105
+ * shell commands), so it gets flattened to one line, capped, and stripped of
4106
+ * the two Discord mass-mention triggers. Neither channel renders it with a
4107
+ * parse mode, so no markup escaping is needed beyond that.
4108
+ */
4109
+ static sanitizeActivity(activity) {
4110
+ if (!activity)
4111
+ return null;
4112
+ const flat = activity.replace(/\s+/g, " ").replace(/@(everyone|here)/g, "@​$1").trim();
4113
+ if (!flat)
4114
+ return null;
4115
+ return flat.length > PROGRESS_ACTIVITY_MAX_CHARS
4116
+ ? `${flat.slice(0, PROGRESS_ACTIVITY_MAX_CHARS - 1)}…`
4117
+ : flat;
4118
+ }
4119
+ /**
4120
+ * Remember what an instance is currently doing, for the progress line.
4121
+ *
4122
+ * Best-effort by design: only backends that expose a live activity feed report
4123
+ * anything, and the progress line simply omits the detail for the rest. It is
4124
+ * never used to decide anything — purely what the user is shown.
4125
+ */
4126
+ cacheInstanceActivity(name, activity) {
4127
+ if (activity)
4128
+ this.instanceActivity.set(name, activity);
4129
+ else
4130
+ this.instanceActivity.delete(name);
4131
+ }
4132
+ /**
4133
+ * Refresh the button's text in place while the instance keeps working.
4134
+ *
4135
+ * Uses `editAlert`, NOT `editMessage`: on Telegram the latter omits reply_markup,
4136
+ * and the Bot API treats that as "clear the keyboard" — so editing with it would
4137
+ * delete the very cancel button this is trying to keep alive.
4138
+ */
4139
+ startProgressTicker(entry) {
4140
+ entry.progressTimer = setInterval(() => {
4141
+ if (!this.cancelButtons.has(entry.messageId)) {
4142
+ clearInterval(entry.progressTimer);
4143
+ return;
4144
+ }
4145
+ // Idle means the turn ended; the idle-edge handler retires the button.
4146
+ if (this.getInstanceIdle(entry.instanceName))
4147
+ return;
4148
+ const text = FleetManager.progressText(Date.now() - (entry.startedAt ?? Date.now()), this.instanceActivity.get(entry.instanceName));
4149
+ if (text === entry.lastProgressText)
4150
+ return; // nothing changed — skip the API call
4151
+ const adapter = this.getAdapterForInstance(entry.instanceName) ?? this.adapter;
4152
+ if (!adapter?.editAlert)
4153
+ return;
4154
+ entry.lastProgressText = text;
4155
+ adapter.editAlert(entry.chatId, entry.messageId, {
4156
+ type: "cancel",
4157
+ instanceName: entry.instanceName,
4158
+ message: text,
4159
+ choices: [{ id: `cancel:${entry.instanceName}`, label: t("cancel.button") }],
4160
+ }, entry.threadId ? { threadId: entry.threadId } : undefined)
4161
+ .catch(err => {
4162
+ // A failed progress edit must never escalate: the button still works and
4163
+ // the next tick retries. Common causes are a deleted message or a
4164
+ // rate limit.
4165
+ this.logger.debug({ err, instanceName: entry.instanceName }, "Progress edit failed");
4166
+ });
4167
+ }, PROGRESS_UPDATE_INTERVAL_MS);
4168
+ entry.progressTimer.unref?.();
4169
+ }
3557
4170
  /** Retire (delete) every cancel button belonging to an instance. */
3558
4171
  retireInstanceButtons(instanceName) {
3559
4172
  // Snapshot first — retireButton may delete entries from the map on success.
@@ -3585,6 +4198,8 @@ export class FleetManager {
3585
4198
  clearTimeout(entry.retryTimer);
3586
4199
  if (entry.idleCheckTimer)
3587
4200
  clearInterval(entry.idleCheckTimer);
4201
+ if (entry.progressTimer)
4202
+ clearInterval(entry.progressTimer);
3588
4203
  this.cancelButtons.delete(entry.messageId);
3589
4204
  }
3590
4205
  /** Re-attempt a failed button delete up to CANCEL_BTN_MAX_RETRIES times. */
@@ -4300,22 +4915,49 @@ When users create specialized instances, suggest these configurations:
4300
4915
  catch { /* missing / stale / corrupt */ }
4301
4916
  return null;
4302
4917
  }
4303
- /** Resolve the model currently configured for a fleet or ClassicBot instance. */
4304
- currentModelForInstance(instanceName) {
4918
+ /**
4919
+ * Resolve the effective model for a fleet or ClassicBot instance, plus where it
4920
+ * came from. Single source of truth for `/model` and `/ctx` — precedence:
4921
+ * per-instance → fleet defaults → classic channel → CLI's own default (from the
4922
+ * cli-env probe cache) → unresolved.
4923
+ */
4924
+ resolveInstanceModel(instanceName) {
4925
+ const done = (model, source, reason) => ({
4926
+ model,
4927
+ source,
4928
+ reason,
4929
+ // Make an inherited CLI default legible instead of the bare word "default".
4930
+ display: source === "cli-default" ? `${model} (default)`
4931
+ : source === "unresolved" ? `default (${reason ?? "unresolved"})`
4932
+ : model,
4933
+ });
4305
4934
  const fleetInstance = this.fleetConfig?.instances[instanceName];
4306
4935
  if (fleetInstance) {
4307
- const fleetModel = fleetInstance.model ?? this.fleetConfig?.defaults?.model;
4308
- if (fleetModel?.trim())
4309
- return fleetModel.trim();
4936
+ if (fleetInstance.model?.trim())
4937
+ return done(fleetInstance.model.trim(), "instance");
4938
+ const fleetDefault = this.fleetConfig?.defaults?.model;
4939
+ if (fleetDefault?.trim())
4940
+ return done(fleetDefault.trim(), "fleet-default");
4310
4941
  }
4311
4942
  const classic = this.classicChannels?.getAll().find(ch => ch.instanceName === instanceName);
4312
4943
  if (classic) {
4313
4944
  const classicModel = this.classicChannels?.getModel(classic.channelId, classic.adapterId, this.fleetConfig?.defaults?.model);
4314
4945
  if (classicModel?.trim())
4315
- return classicModel.trim();
4316
- }
4317
- const cachedModel = this.readCliEnv(this.backendNameForInstance(instanceName))?.currentModel;
4318
- return cachedModel?.trim() || "default";
4946
+ return done(classicModel.trim(), "classic");
4947
+ }
4948
+ // Nothing configured → show what the CLI itself defaults to (kiro default_model,
4949
+ // grok "Default model:", codex config.toml, agy settings.json), cached by the probe.
4950
+ const cliEnv = this.readCliEnv(this.backendNameForInstance(instanceName));
4951
+ const cachedModel = cliEnv?.currentModel;
4952
+ if (cachedModel?.trim())
4953
+ return done(cachedModel.trim(), "cli-default");
4954
+ // Say WHY it's unresolved: no fresh probe yet vs. the CLI not exposing a default
4955
+ // (e.g. claude-code's default is account-side, opencode's is provider-side).
4956
+ return done("default", "unresolved", cliEnv ? "this CLI does not report a default" : "not probed yet");
4957
+ }
4958
+ /** Human-readable effective model, e.g. `auto (default)`. Used by /ctx. */
4959
+ modelDisplayForInstance(instanceName) {
4960
+ return this.resolveInstanceModel(instanceName).display;
4319
4961
  }
4320
4962
  modelChoiceLabel(option, currentModel) {
4321
4963
  const label = option.description ? `${option.label} — ${option.description}` : option.label;
@@ -4395,7 +5037,8 @@ When users create specialized instances, suggest these configurations:
4395
5037
  await data.respond(`No model list available for ${name}. Type \`/model <name>\` to set one directly.`);
4396
5038
  return;
4397
5039
  }
4398
- const currentModel = this.currentModelForInstance(name);
5040
+ // Raw id for ✓-matching options; display resolves an inherited CLI default.
5041
+ const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(name);
4399
5042
  const nonce = randomBytes(6).toString("hex");
4400
5043
  const choices = options.slice(0, 25).map(o => ({
4401
5044
  id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`,
@@ -4405,7 +5048,7 @@ When users create specialized instances, suggest these configurations:
4405
5048
  timer.unref?.();
4406
5049
  this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
4407
5050
  try {
4408
- await data.respondChoices(`Current model: **${currentModel}**\nSelect a new model:`, choices);
5051
+ await data.respondChoices(`Current model: **${currentDisplay}**\nSelect a new model:`, choices);
4409
5052
  }
4410
5053
  catch (err) {
4411
5054
  this.pendingModelSelects.delete(nonce);
@@ -4424,7 +5067,7 @@ When users create specialized instances, suggest these configurations:
4424
5067
  if (options.length === 0) {
4425
5068
  return `No model list available for ${instanceName}. Use \`/model <name>\` to set one directly.`;
4426
5069
  }
4427
- const currentModel = this.currentModelForInstance(instanceName);
5070
+ const { model: currentModel, display: currentDisplay } = this.resolveInstanceModel(instanceName);
4428
5071
  const nonce = randomBytes(6).toString("hex");
4429
5072
  const choices = options.slice(0, 25).map(o => ({
4430
5073
  id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`,
@@ -4444,7 +5087,7 @@ When users create specialized instances, suggest these configurations:
4444
5087
  timer.unref?.();
4445
5088
  this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
4446
5089
  try {
4447
- const menuMessageId = await adapter.promptUser(chatId, `Current model: ${currentModel}\nSelect a new model:`, choices, { threadId });
5090
+ const menuMessageId = await adapter.promptUser(chatId, `Current model: ${currentDisplay}\nSelect a new model:`, choices, { threadId });
4448
5091
  const pending = this.pendingModelSelects.get(nonce);
4449
5092
  if (pending)
4450
5093
  pending.menuMessageId = menuMessageId;
@@ -4503,9 +5146,19 @@ When users create specialized instances, suggest these configurations:
4503
5146
  // DC path: respond immediately with progress text
4504
5147
  await pending.respond(progressText).catch(() => { });
4505
5148
  }
4506
- // Apply model in background — don't await here (keeps callback handler fast)
5149
+ // Apply model in background — don't await here (keeps callback handler fast).
5150
+ // Guarded: applyModel() restarts the instance, and an unguarded rejection here
5151
+ // meant a user picking from the /model menu could take the whole fleet down.
5152
+ // On failure the user gets told, rather than the click silently doing nothing.
4507
5153
  void (async () => {
4508
- const result = await this.applyModel(pending.instanceName, model);
5154
+ let result;
5155
+ try {
5156
+ result = await this.applyModel(pending.instanceName, model);
5157
+ }
5158
+ catch (err) {
5159
+ this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
5160
+ result = `Model switch to \`${model}\` failed: ${err instanceof Error ? err.message : String(err)}`;
5161
+ }
4509
5162
  if (pending.adapter && pending.adapterChatId) {
4510
5163
  if (progressMsgId) {
4511
5164
  pending.adapter.editMessage(pending.adapterChatId, progressMsgId, result, pending.adapterThreadId).catch(() => {
@@ -4743,7 +5396,7 @@ When users create specialized instances, suggest these configurations:
4743
5396
  ...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}),
4744
5397
  };
4745
5398
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
4746
- await this.startInstance(instanceName, config, topicMode);
5399
+ await this.startInstance(instanceName, config, topicMode, "classic");
4747
5400
  }
4748
5401
  /** Handle /start slash command — register classic channel */
4749
5402
  async handleClassicStart(channelId, channelName, userId, guildId, adapterId, backend) {
@@ -4785,11 +5438,24 @@ When users create specialized instances, suggest these configurations:
4785
5438
  this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
4786
5439
  return t("classic.stopped");
4787
5440
  }
4788
- async stopAll() {
5441
+ /**
5442
+ * Idempotent while in flight: SIGINT and SIGTERM share one handler and the
5443
+ * uncaughtException path calls this too, so overlapping runs were possible —
5444
+ * each snapshotting the daemon map and calling stop() on the same daemons
5445
+ * concurrently. Deliberately NOT `async`, so callers receive the same promise
5446
+ * object rather than a fresh wrapper around it. The latch clears when the run
5447
+ * settles, so a later genuine stop (after a restart) still does the work.
5448
+ */
5449
+ stopAll() {
5450
+ this.stopAllInFlight ??= this.doStopAll().finally(() => { this.stopAllInFlight = null; });
5451
+ return this.stopAllInFlight;
5452
+ }
5453
+ stopAllInFlight = null;
5454
+ async doStopAll() {
4789
5455
  this.startupComplete = false;
4790
5456
  this.reloadPending = false;
4791
5457
  this.ipcStoppingInstances.add("__fleet_stopping__");
4792
- sdNotify("STOPPING=1");
5458
+ sdNotifyBlocking("STOPPING=1");
4793
5459
  if (this.watchdogTimer) {
4794
5460
  clearInterval(this.watchdogTimer);
4795
5461
  this.watchdogTimer = null;
@@ -4809,6 +5475,26 @@ When users create specialized instances, suggest these configurations:
4809
5475
  clearInterval(this.updateCheckTimer);
4810
5476
  this.updateCheckTimer = null;
4811
5477
  }
5478
+ if (this.eventLogPruneTimer) {
5479
+ clearInterval(this.eventLogPruneTimer);
5480
+ this.eventLogPruneTimer = null;
5481
+ }
5482
+ if (this.logRotateTimer) {
5483
+ clearInterval(this.logRotateTimer);
5484
+ this.logRotateTimer = null;
5485
+ }
5486
+ // Cancel-button timers were never cleared here. The idle-check interval is not
5487
+ // unref'd, so it held the event loop open past shutdown and kept retrying
5488
+ // deletes against an adapter that was already gone.
5489
+ for (const entry of [...this.cancelButtons.values()]) {
5490
+ if (entry.retryTimer)
5491
+ clearTimeout(entry.retryTimer);
5492
+ if (entry.idleCheckTimer)
5493
+ clearInterval(entry.idleCheckTimer);
5494
+ if (entry.progressTimer)
5495
+ clearInterval(entry.progressTimer);
5496
+ }
5497
+ this.cancelButtons.clear();
4812
5498
  if (this.topicCleanupTimer) {
4813
5499
  clearInterval(this.topicCleanupTimer);
4814
5500
  this.topicCleanupTimer = null;
@@ -5027,6 +5713,15 @@ When users create specialized instances, suggest these configurations:
5027
5713
  removedRatio,
5028
5714
  validationErrors: validation.errors,
5029
5715
  }, "Refusing unsafe fleet config reload; running configuration was kept");
5716
+ // Tell the operator. A silently ignored config edit is the most confusing
5717
+ // possible outcome: they change fleet.yaml, send SIGHUP, and nothing happens
5718
+ // with no explanation anywhere they are looking.
5719
+ const why = !validation.valid
5720
+ ? `validation failed:\n${validation.errors.map(e => `• ${e.path}: ${e.message}`).join("\n")}`
5721
+ : unsafeEmpty
5722
+ ? `it removed every instance (${oldCount} → 0)`
5723
+ : `it removed more than half the instances (${oldCount} → ${newCount})`;
5724
+ this.notifyFleetError(`⚠️ fleet.yaml reload REJECTED — ${why}\nThe previous configuration is still running.`);
5030
5725
  return;
5031
5726
  }
5032
5727
  this.routing.rebuild(this.fleetConfig);
@@ -5226,10 +5921,20 @@ When users create specialized instances, suggest these configurations:
5226
5921
  // ── Update check ────────────────────────────────────────────────────
5227
5922
  async checkForUpdates() {
5228
5923
  try {
5229
- const { execSync } = await import("node:child_process");
5924
+ // Both npm lookups are async: as execSync they froze the fleet event loop for
5925
+ // up to 15s each, and on a beta build BOTH ran — 30s with no WATCHDOG ping,
5926
+ // past WatchdogSec's half-interval and enough for systemd to SIGABRT the fleet
5927
+ // for a background version check.
5928
+ const { execFile } = await import("node:child_process");
5929
+ const { promisify } = await import("node:util");
5930
+ const execFileP = promisify(execFile);
5931
+ const npmVersion = async (spec) => {
5932
+ const { stdout } = await execFileP("npm", ["view", spec, "version"], { timeout: 15_000 });
5933
+ return stdout.toString().trim();
5934
+ };
5230
5935
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
5231
5936
  const currentVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "0.0.0";
5232
- const latest = execSync("npm view @songsid/agend version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
5937
+ const latest = await npmVersion("@songsid/agend");
5233
5938
  let target = latest;
5234
5939
  if (currentVersion.includes("-beta")) {
5235
5940
  // Beta users track the @beta channel (never fall back to @latest, which is
@@ -5237,7 +5942,7 @@ When users create specialized instances, suggest these configurations:
5237
5942
  // of beta/latest is the newest.
5238
5943
  let beta = "";
5239
5944
  try {
5240
- beta = execSync("npm view @songsid/agend@beta version", { stdio: "pipe", timeout: 15_000 }).toString().trim();
5945
+ beta = await npmVersion("@songsid/agend@beta");
5241
5946
  }
5242
5947
  catch { /* no beta tag */ }
5243
5948
  target = beta || latest;
@@ -5344,6 +6049,10 @@ When users create specialized instances, suggest these configurations:
5344
6049
  // /view routes accept the read-only view.token (or web.token) and do
5345
6050
  // their own per-method auth in view-api.ts — skip the web-token gate.
5346
6051
  }
6052
+ else if (isUsagePath(new URL(req.url ?? "/", `http://localhost:${port}`).pathname)) {
6053
+ // /api/ai-usage is read-only GET data for the /view Usage panel — open
6054
+ // like the other /view data routes (usage-api.ts rejects non-GET).
6055
+ }
5347
6056
  else {
5348
6057
  // All other endpoints require a valid token (query ?token= or X-Agend-Token header).
5349
6058
  // /ui/* will also re-check in web-api.ts, which is harmless.
@@ -5358,34 +6067,34 @@ When users create specialized instances, suggest these configurations:
5358
6067
  }
5359
6068
  }
5360
6069
  if (req.method === "GET" && req.url === "/health") {
5361
- const instanceCount = this.fleetConfig?.instances
5362
- ? Object.keys(this.fleetConfig.instances).length
5363
- : 0;
5364
- res.writeHead(200);
5365
- res.end(JSON.stringify({
5366
- status: "ok",
5367
- instances: instanceCount,
5368
- uptime: Math.floor((Date.now() - this.startedAt) / 1000),
5369
- }));
6070
+ const health = this.getFleetHealth();
6071
+ // 503 when the fleet cannot do its job, so an external monitor sees it.
6072
+ // This used to always answer 200 "ok" with a count of CONFIGURED instances,
6073
+ // so every agent could be dead and every adapter down and it still looked
6074
+ // green.
6075
+ res.writeHead(health.status === "ok" ? 200 : 503);
6076
+ res.end(JSON.stringify(health));
5370
6077
  return;
5371
6078
  }
5372
6079
  if (req.method === "GET" && req.url === "/status") {
5373
6080
  const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => {
5374
6081
  const statusFile = join(this.getInstanceDir(name), "statusline.json");
5375
- let context_pct = 0;
5376
6082
  let cost = 0;
5377
6083
  try {
5378
6084
  const data = JSON.parse(readFileSync(statusFile, "utf-8"));
5379
- context_pct = data.context_window?.used_percentage ?? 0;
5380
6085
  cost = data.cost?.total_cost_usd ?? 0;
5381
6086
  }
5382
6087
  catch (err) {
5383
6088
  this.logger.debug({ err, name }, "statusline.json read failed (/status)");
5384
6089
  }
6090
+ const backend = this.fleetConfig?.instances[name]?.backend
6091
+ ?? this.fleetConfig?.defaults?.backend
6092
+ ?? "claude-code";
6093
+ const { context } = resolveInstanceContext(this.dataDir, name, backend);
5385
6094
  return {
5386
6095
  name,
5387
6096
  status: this.getInstanceStatus(name),
5388
- context_pct,
6097
+ context_pct: context ?? 0,
5389
6098
  cost,
5390
6099
  };
5391
6100
  });
@@ -5500,7 +6209,10 @@ When users create specialized instances, suggest these configurations:
5500
6209
  res.writeHead(500);
5501
6210
  res.end(JSON.stringify({ error: `Start failed: ${err.message}` }));
5502
6211
  }
5503
- })();
6212
+ // The inner catch can itself throw (writeHead after a successful
6213
+ // writeHead is ERR_HTTP_HEADERS_SENT), and that rejection escapes the
6214
+ // IIFE. Same for the two handlers below.
6215
+ })().catch(err => this.logger.error({ err, name }, "HTTP start handler failed"));
5504
6216
  return;
5505
6217
  }
5506
6218
  // Instance restart (immediate, no idle wait)
@@ -5521,7 +6233,7 @@ When users create specialized instances, suggest these configurations:
5521
6233
  res.writeHead(status);
5522
6234
  res.end(JSON.stringify({ error: `Restart failed: ${err.message}` }));
5523
6235
  }
5524
- })();
6236
+ })().catch(err => this.logger.error({ err, name }, "HTTP restart handler failed"));
5525
6237
  return;
5526
6238
  }
5527
6239
  if (req.method === "POST" && req.url?.startsWith("/stop/")) {
@@ -5544,7 +6256,7 @@ When users create specialized instances, suggest these configurations:
5544
6256
  res.writeHead(500);
5545
6257
  res.end(JSON.stringify({ error: `Stop failed: ${err.message}` }));
5546
6258
  }
5547
- })();
6259
+ })().catch(err => this.logger.error({ err, name }, "HTTP stop handler failed"));
5548
6260
  return;
5549
6261
  }
5550
6262
  // ── Agent CLI endpoint ─────
@@ -5556,6 +6268,8 @@ When users create specialized instances, suggest these configurations:
5556
6268
  const url = new URL(req.url ?? "/", `http://localhost:${port}`);
5557
6269
  if (handleViewRequest(req, res, url, this))
5558
6270
  return;
6271
+ if (handleUsageRequest(req, res, url, this))
6272
+ return;
5559
6273
  if (handleSettingsRequest(req, res, url, this))
5560
6274
  return;
5561
6275
  if (handleWebRequest(req, res, url, this))
@@ -5602,20 +6316,35 @@ When users create specialized instances, suggest these configurations:
5602
6316
  this.logger.info({ url: `http://localhost:${port}/view?token=${this.viewToken}` }, "Web View available");
5603
6317
  }
5604
6318
  getUiStatus() {
5605
- const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => {
6319
+ const fleetNames = Object.keys(this.fleetConfig?.instances ?? {});
6320
+ // Classic rooms live only in classicBot.yaml — /api/profiles merges them into
6321
+ // the View roster, but previously getUiStatus skipped them so context_pct was
6322
+ // always 0 (live map miss → l?.context_pct ?? 0).
6323
+ const classicOnly = (this.classicChannels?.getAll() ?? [])
6324
+ .map(ch => ch.instanceName)
6325
+ .filter(name => !fleetNames.includes(name));
6326
+ const names = [...fleetNames, ...classicOnly];
6327
+ const instances = names.map(name => {
5606
6328
  const statusFile = join(this.getInstanceDir(name), "statusline.json");
5607
- let context_pct = 0;
5608
6329
  let cost = 0;
5609
6330
  let model = "";
5610
6331
  try {
5611
6332
  const data = JSON.parse(readFileSync(statusFile, "utf-8"));
5612
- context_pct = data.context_window?.used_percentage ?? 0;
5613
6333
  cost = data.cost?.total_cost_usd ?? 0;
5614
6334
  model = data.model?.display_name ?? "";
5615
6335
  }
5616
6336
  catch (err) {
5617
6337
  this.logger.debug({ err, name }, "statusline.json read failed (getUiStatus)");
5618
6338
  }
6339
+ // Align with /ctx: statusline for claude-code, pane scrape for kiro/grok/codex.
6340
+ const classic = classicOnly.includes(name);
6341
+ const backend = classic
6342
+ ? this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend)
6343
+ : (this.fleetConfig?.instances[name]?.backend
6344
+ ?? this.fleetConfig?.defaults?.backend
6345
+ ?? "claude-code");
6346
+ const { context } = resolveInstanceContext(this.dataDir, name, backend);
6347
+ const context_pct = context ?? 0;
5619
6348
  return { name, status: this.getInstanceStatus(name), context_pct, cost, model };
5620
6349
  });
5621
6350
  return {