@songsid/agend 0.0.1

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 (232) hide show
  1. package/README.md +210 -0
  2. package/README.zh-TW.md +134 -0
  3. package/dist/access-path.d.ts +10 -0
  4. package/dist/access-path.js +32 -0
  5. package/dist/access-path.js.map +1 -0
  6. package/dist/adapter-world.d.ts +25 -0
  7. package/dist/adapter-world.js +41 -0
  8. package/dist/adapter-world.js.map +1 -0
  9. package/dist/agent-cli-instructions.md +50 -0
  10. package/dist/agent-cli.d.ts +2 -0
  11. package/dist/agent-cli.js +200 -0
  12. package/dist/agent-cli.js.map +1 -0
  13. package/dist/agent-endpoint.d.ts +25 -0
  14. package/dist/agent-endpoint.js +162 -0
  15. package/dist/agent-endpoint.js.map +1 -0
  16. package/dist/backend/antigravity.d.ts +17 -0
  17. package/dist/backend/antigravity.js +98 -0
  18. package/dist/backend/antigravity.js.map +1 -0
  19. package/dist/backend/claude-code.d.ts +23 -0
  20. package/dist/backend/claude-code.js +171 -0
  21. package/dist/backend/claude-code.js.map +1 -0
  22. package/dist/backend/codex.d.ts +18 -0
  23. package/dist/backend/codex.js +160 -0
  24. package/dist/backend/codex.js.map +1 -0
  25. package/dist/backend/factory.d.ts +2 -0
  26. package/dist/backend/factory.js +28 -0
  27. package/dist/backend/factory.js.map +1 -0
  28. package/dist/backend/gemini-cli.d.ts +17 -0
  29. package/dist/backend/gemini-cli.js +163 -0
  30. package/dist/backend/gemini-cli.js.map +1 -0
  31. package/dist/backend/index.d.ts +7 -0
  32. package/dist/backend/index.js +7 -0
  33. package/dist/backend/index.js.map +1 -0
  34. package/dist/backend/kiro.d.ts +17 -0
  35. package/dist/backend/kiro.js +147 -0
  36. package/dist/backend/kiro.js.map +1 -0
  37. package/dist/backend/marker-utils.d.ts +13 -0
  38. package/dist/backend/marker-utils.js +64 -0
  39. package/dist/backend/marker-utils.js.map +1 -0
  40. package/dist/backend/mock.d.ts +25 -0
  41. package/dist/backend/mock.js +85 -0
  42. package/dist/backend/mock.js.map +1 -0
  43. package/dist/backend/opencode.d.ts +16 -0
  44. package/dist/backend/opencode.js +136 -0
  45. package/dist/backend/opencode.js.map +1 -0
  46. package/dist/backend/types.d.ts +86 -0
  47. package/dist/backend/types.js +33 -0
  48. package/dist/backend/types.js.map +1 -0
  49. package/dist/channel/access-manager.d.ts +18 -0
  50. package/dist/channel/access-manager.js +153 -0
  51. package/dist/channel/access-manager.js.map +1 -0
  52. package/dist/channel/adapters/telegram.d.ts +63 -0
  53. package/dist/channel/adapters/telegram.js +646 -0
  54. package/dist/channel/adapters/telegram.js.map +1 -0
  55. package/dist/channel/attachment-handler.d.ts +15 -0
  56. package/dist/channel/attachment-handler.js +88 -0
  57. package/dist/channel/attachment-handler.js.map +1 -0
  58. package/dist/channel/factory.d.ts +12 -0
  59. package/dist/channel/factory.js +67 -0
  60. package/dist/channel/factory.js.map +1 -0
  61. package/dist/channel/ipc-bridge.d.ts +26 -0
  62. package/dist/channel/ipc-bridge.js +220 -0
  63. package/dist/channel/ipc-bridge.js.map +1 -0
  64. package/dist/channel/mcp-server.d.ts +10 -0
  65. package/dist/channel/mcp-server.js +288 -0
  66. package/dist/channel/mcp-server.js.map +1 -0
  67. package/dist/channel/mcp-tools.d.ts +17 -0
  68. package/dist/channel/mcp-tools.js +110 -0
  69. package/dist/channel/mcp-tools.js.map +1 -0
  70. package/dist/channel/message-bus.d.ts +17 -0
  71. package/dist/channel/message-bus.js +86 -0
  72. package/dist/channel/message-bus.js.map +1 -0
  73. package/dist/channel/message-queue.d.ts +39 -0
  74. package/dist/channel/message-queue.js +253 -0
  75. package/dist/channel/message-queue.js.map +1 -0
  76. package/dist/channel/tool-router.d.ts +6 -0
  77. package/dist/channel/tool-router.js +75 -0
  78. package/dist/channel/tool-router.js.map +1 -0
  79. package/dist/channel/tool-tracker.d.ts +13 -0
  80. package/dist/channel/tool-tracker.js +58 -0
  81. package/dist/channel/tool-tracker.js.map +1 -0
  82. package/dist/channel/types.d.ts +118 -0
  83. package/dist/channel/types.js +2 -0
  84. package/dist/channel/types.js.map +1 -0
  85. package/dist/chat-export.d.ts +4 -0
  86. package/dist/chat-export.js +91 -0
  87. package/dist/chat-export.js.map +1 -0
  88. package/dist/classic-channel-manager.d.ts +59 -0
  89. package/dist/classic-channel-manager.js +193 -0
  90. package/dist/classic-channel-manager.js.map +1 -0
  91. package/dist/cli.d.ts +2 -0
  92. package/dist/cli.js +1833 -0
  93. package/dist/cli.js.map +1 -0
  94. package/dist/config.d.ts +9 -0
  95. package/dist/config.js +118 -0
  96. package/dist/config.js.map +1 -0
  97. package/dist/context-guardian.d.ts +26 -0
  98. package/dist/context-guardian.js +73 -0
  99. package/dist/context-guardian.js.map +1 -0
  100. package/dist/cost-guard.d.ts +36 -0
  101. package/dist/cost-guard.js +147 -0
  102. package/dist/cost-guard.js.map +1 -0
  103. package/dist/daemon-entry.d.ts +1 -0
  104. package/dist/daemon-entry.js +29 -0
  105. package/dist/daemon-entry.js.map +1 -0
  106. package/dist/daemon.d.ts +152 -0
  107. package/dist/daemon.js +1714 -0
  108. package/dist/daemon.js.map +1 -0
  109. package/dist/daily-summary.d.ts +13 -0
  110. package/dist/daily-summary.js +55 -0
  111. package/dist/daily-summary.js.map +1 -0
  112. package/dist/event-log.d.ts +36 -0
  113. package/dist/event-log.js +100 -0
  114. package/dist/event-log.js.map +1 -0
  115. package/dist/export-import.d.ts +2 -0
  116. package/dist/export-import.js +162 -0
  117. package/dist/export-import.js.map +1 -0
  118. package/dist/fleet-context.d.ts +61 -0
  119. package/dist/fleet-context.js +4 -0
  120. package/dist/fleet-context.js.map +1 -0
  121. package/dist/fleet-dashboard-html.d.ts +6 -0
  122. package/dist/fleet-dashboard-html.js +443 -0
  123. package/dist/fleet-dashboard-html.js.map +1 -0
  124. package/dist/fleet-health-server.d.ts +35 -0
  125. package/dist/fleet-health-server.js +290 -0
  126. package/dist/fleet-health-server.js.map +1 -0
  127. package/dist/fleet-instructions.d.ts +5 -0
  128. package/dist/fleet-instructions.js +161 -0
  129. package/dist/fleet-instructions.js.map +1 -0
  130. package/dist/fleet-manager.d.ts +212 -0
  131. package/dist/fleet-manager.js +3655 -0
  132. package/dist/fleet-manager.js.map +1 -0
  133. package/dist/fleet-rpc-handlers.d.ts +42 -0
  134. package/dist/fleet-rpc-handlers.js +356 -0
  135. package/dist/fleet-rpc-handlers.js.map +1 -0
  136. package/dist/fleet-system-prompt.d.ts +11 -0
  137. package/dist/fleet-system-prompt.js +61 -0
  138. package/dist/fleet-system-prompt.js.map +1 -0
  139. package/dist/general-knowledge/skills.md +177 -0
  140. package/dist/hang-detector.d.ts +16 -0
  141. package/dist/hang-detector.js +53 -0
  142. package/dist/hang-detector.js.map +1 -0
  143. package/dist/index.d.ts +8 -0
  144. package/dist/index.js +6 -0
  145. package/dist/index.js.map +1 -0
  146. package/dist/instance-lifecycle.d.ts +90 -0
  147. package/dist/instance-lifecycle.js +592 -0
  148. package/dist/instance-lifecycle.js.map +1 -0
  149. package/dist/instructions.d.ts +15 -0
  150. package/dist/instructions.js +90 -0
  151. package/dist/instructions.js.map +1 -0
  152. package/dist/logger.d.ts +7 -0
  153. package/dist/logger.js +84 -0
  154. package/dist/logger.js.map +1 -0
  155. package/dist/outbound-handlers.d.ts +51 -0
  156. package/dist/outbound-handlers.js +739 -0
  157. package/dist/outbound-handlers.js.map +1 -0
  158. package/dist/outbound-schemas.d.ts +238 -0
  159. package/dist/outbound-schemas.js +248 -0
  160. package/dist/outbound-schemas.js.map +1 -0
  161. package/dist/paths.d.ts +10 -0
  162. package/dist/paths.js +42 -0
  163. package/dist/paths.js.map +1 -0
  164. package/dist/plugin/agend/.claude-plugin/plugin.json +5 -0
  165. package/dist/quickstart.d.ts +1 -0
  166. package/dist/quickstart.js +595 -0
  167. package/dist/quickstart.js.map +1 -0
  168. package/dist/routing-engine.d.ts +22 -0
  169. package/dist/routing-engine.js +44 -0
  170. package/dist/routing-engine.js.map +1 -0
  171. package/dist/safe-async.d.ts +6 -0
  172. package/dist/safe-async.js +20 -0
  173. package/dist/safe-async.js.map +1 -0
  174. package/dist/scheduler/db.d.ts +37 -0
  175. package/dist/scheduler/db.js +360 -0
  176. package/dist/scheduler/db.js.map +1 -0
  177. package/dist/scheduler/db.test.d.ts +1 -0
  178. package/dist/scheduler/db.test.js +92 -0
  179. package/dist/scheduler/db.test.js.map +1 -0
  180. package/dist/scheduler/index.d.ts +4 -0
  181. package/dist/scheduler/index.js +4 -0
  182. package/dist/scheduler/index.js.map +1 -0
  183. package/dist/scheduler/scheduler.d.ts +44 -0
  184. package/dist/scheduler/scheduler.js +197 -0
  185. package/dist/scheduler/scheduler.js.map +1 -0
  186. package/dist/scheduler/scheduler.test.d.ts +1 -0
  187. package/dist/scheduler/scheduler.test.js +119 -0
  188. package/dist/scheduler/scheduler.test.js.map +1 -0
  189. package/dist/scheduler/types.d.ts +107 -0
  190. package/dist/scheduler/types.js +7 -0
  191. package/dist/scheduler/types.js.map +1 -0
  192. package/dist/service-installer.d.ts +17 -0
  193. package/dist/service-installer.js +182 -0
  194. package/dist/service-installer.js.map +1 -0
  195. package/dist/setup-wizard.d.ts +48 -0
  196. package/dist/setup-wizard.js +701 -0
  197. package/dist/setup-wizard.js.map +1 -0
  198. package/dist/statusline-watcher.d.ts +34 -0
  199. package/dist/statusline-watcher.js +73 -0
  200. package/dist/statusline-watcher.js.map +1 -0
  201. package/dist/stt.d.ts +10 -0
  202. package/dist/stt.js +33 -0
  203. package/dist/stt.js.map +1 -0
  204. package/dist/tmux-control.d.ts +52 -0
  205. package/dist/tmux-control.js +207 -0
  206. package/dist/tmux-control.js.map +1 -0
  207. package/dist/tmux-manager.d.ts +44 -0
  208. package/dist/tmux-manager.js +218 -0
  209. package/dist/tmux-manager.js.map +1 -0
  210. package/dist/topic-archiver.d.ts +40 -0
  211. package/dist/topic-archiver.js +103 -0
  212. package/dist/topic-archiver.js.map +1 -0
  213. package/dist/topic-commands.d.ts +28 -0
  214. package/dist/topic-commands.js +359 -0
  215. package/dist/topic-commands.js.map +1 -0
  216. package/dist/transcript-monitor.d.ts +23 -0
  217. package/dist/transcript-monitor.js +164 -0
  218. package/dist/transcript-monitor.js.map +1 -0
  219. package/dist/types.d.ts +211 -0
  220. package/dist/types.js +2 -0
  221. package/dist/types.js.map +1 -0
  222. package/dist/ui/dashboard.html +719 -0
  223. package/dist/web-api.d.ts +101 -0
  224. package/dist/web-api.js +648 -0
  225. package/dist/web-api.js.map +1 -0
  226. package/dist/webhook-emitter.d.ts +15 -0
  227. package/dist/webhook-emitter.js +41 -0
  228. package/dist/webhook-emitter.js.map +1 -0
  229. package/dist/workflow-templates/default.md +35 -0
  230. package/package.json +76 -0
  231. package/templates/launchd.plist.ejs +31 -0
  232. package/templates/systemd.service.ejs +16 -0
@@ -0,0 +1,3655 @@
1
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
3
+ import { createServer } from "node:http";
4
+ import { join, dirname, basename } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { getAgendHome } from "./paths.js";
7
+ import yaml from "js-yaml";
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ import { isProbeableRouteTarget } from "./fleet-context.js";
11
+ import { loadFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
12
+ import { EventLog } from "./event-log.js";
13
+ import { AdapterWorld } from "./adapter-world.js";
14
+ import { CostGuard, formatCents } from "./cost-guard.js";
15
+ import { TmuxManager } from "./tmux-manager.js";
16
+ import { AccessManager } from "./channel/access-manager.js";
17
+ import { IpcClient } from "./channel/ipc-bridge.js";
18
+ import { createAdapter } from "./channel/factory.js";
19
+ import { createLogger } from "./logger.js";
20
+ import { processAttachments } from "./channel/attachment-handler.js";
21
+ import { routeToolCall } from "./channel/tool-router.js";
22
+ import { Scheduler } from "./scheduler/index.js";
23
+ import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
24
+ import { TopicCommands, sanitizeInstanceName } from "./topic-commands.js";
25
+ import { DailySummary } from "./daily-summary.js";
26
+ import { WebhookEmitter } from "./webhook-emitter.js";
27
+ import { TmuxControlClient } from "./tmux-control.js";
28
+ import { safeHandler } from "./safe-async.js";
29
+ import { RoutingEngine } from "./routing-engine.js";
30
+ import { InstanceLifecycle } from "./instance-lifecycle.js";
31
+ import { TopicArchiver } from "./topic-archiver.js";
32
+ import { StatuslineWatcher } from "./statusline-watcher.js";
33
+ import { outboundHandlers } from "./outbound-handlers.js";
34
+ import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
35
+ import { handleAgentRequest } from "./agent-endpoint.js";
36
+ import { ClassicChannelManager, classicInstanceName } from "./classic-channel-manager.js";
37
+ import { getTmuxSession } from "./config.js";
38
+ export function resolveReplyThreadId(argsThreadId, instanceConfig) {
39
+ if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
40
+ return argsThreadId;
41
+ }
42
+ if (instanceConfig?.general_topic) {
43
+ return undefined;
44
+ }
45
+ return instanceConfig?.topic_id != null ? String(instanceConfig.topic_id) : undefined;
46
+ }
47
+ export class FleetManager {
48
+ dataDir;
49
+ children = new Map();
50
+ lifecycle;
51
+ /** @deprecated Use lifecycle.daemons — kept for backward compat */
52
+ get daemons() { return this.lifecycle.daemons; }
53
+ fleetConfig = null;
54
+ adapter = null;
55
+ worlds = new Map();
56
+ adapters = new Map(); // derived view for backward compat
57
+ /** Track which world each instance is bound to */
58
+ instanceWorldBinding = new Map();
59
+ accessManager = null;
60
+ /** Primary world (first adapter) — used for fleet-level notifications */
61
+ get primaryWorld() { return this.worlds.values().next().value; }
62
+ routing = new RoutingEngine();
63
+ get routingTable() { return this.routing.map; }
64
+ instanceIpcClients = new Map();
65
+ scheduler = null;
66
+ configPath = "";
67
+ logger = createLogger("info");
68
+ topicCommands;
69
+ // sessionName → instanceName mapping for external sessions
70
+ sessionRegistry = new Map();
71
+ eventLog = null;
72
+ costGuard = null;
73
+ statuslineWatcher;
74
+ dailySummary = null;
75
+ webhookEmitter = null;
76
+ // Topic icon + auto-archive state
77
+ topicIcons = {};
78
+ lastActivity = new Map();
79
+ lastInboundUser = new Map(); // instanceName → last username
80
+ topicArchiver;
81
+ controlClient = null;
82
+ classicChannels = null;
83
+ // Model failover state
84
+ failoverActive = new Map(); // instance → current failover model
85
+ // Health endpoint
86
+ healthServer = null;
87
+ startedAt = 0;
88
+ // Mirror topic: buffer cross-instance messages, flush every 3s
89
+ mirrorBuffer = [];
90
+ mirrorTimer = null;
91
+ // Web UI: SSE clients + auth token
92
+ sseClients = new Set();
93
+ webToken = null;
94
+ constructor(dataDir) {
95
+ this.dataDir = dataDir;
96
+ this.lifecycle = new InstanceLifecycle(this);
97
+ this.topicCommands = new TopicCommands(this);
98
+ this.topicArchiver = new TopicArchiver(this);
99
+ this.statuslineWatcher = new StatuslineWatcher(this);
100
+ }
101
+ // ── ArchiverContext bridge ────────────────────────────────────────────
102
+ lastActivityMs(name) {
103
+ return this.lastActivity.get(name) ?? 0;
104
+ }
105
+ // ── LifecycleContext bridge methods ──────────────────────────────────────
106
+ webhookEmit(event, name, data) {
107
+ this.webhookEmitter?.emit(event, name, data);
108
+ }
109
+ // ── SysInfo ────────────────────────────────────────────────────────────
110
+ getSysInfo() {
111
+ const mem = process.memoryUsage();
112
+ const toMB = (b) => Math.round(b / 1024 / 1024 * 10) / 10;
113
+ const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => ({
114
+ name,
115
+ status: this.getInstanceStatus(name),
116
+ ipc: this.instanceIpcClients.has(name),
117
+ costCents: this.costGuard?.getDailyCostCents(name) ?? 0,
118
+ rateLimits: this.statuslineWatcher.getRateLimits(name) ?? null,
119
+ }));
120
+ return {
121
+ uptime_seconds: Math.floor((Date.now() - this.startedAt) / 1000),
122
+ memory_mb: { rss: toMB(mem.rss), heapUsed: toMB(mem.heapUsed), heapTotal: toMB(mem.heapTotal) },
123
+ instances,
124
+ fleet_cost_cents: this.costGuard?.getFleetTotalCents() ?? 0,
125
+ fleet_cost_limit_cents: this.costGuard?.getLimitCents() ?? 0,
126
+ };
127
+ }
128
+ /** Load fleet.yaml and build routing table */
129
+ loadConfig(configPath) {
130
+ this.fleetConfig = loadFleetConfig(configPath);
131
+ return this.fleetConfig;
132
+ }
133
+ /** Build topic routing table: { topicId -> RouteTarget } */
134
+ buildRoutingTable() {
135
+ if (this.fleetConfig) {
136
+ this.routing.rebuild(this.fleetConfig);
137
+ this.reregisterClassicChannels();
138
+ }
139
+ return this.routing.map;
140
+ }
141
+ /** Re-register classic channels after routing rebuild (rebuild clears the table) */
142
+ reregisterClassicChannels() {
143
+ if (!this.classicChannels)
144
+ return;
145
+ const channels = this.classicChannels.getAll();
146
+ for (const ch of channels) {
147
+ this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
148
+ }
149
+ // Always update adapter openChannels (including empty — clears stale entries on /stop)
150
+ for (const [, w] of this.worlds) {
151
+ if (typeof w.adapter?.setOpenChannels === "function") {
152
+ w.adapter.setOpenChannels(channels.map(ch => ch.channelId));
153
+ }
154
+ }
155
+ if (channels.length > 0) {
156
+ this.logger.info({ count: channels.length }, "Registered classic channel routes");
157
+ }
158
+ }
159
+ getInstanceDir(name) {
160
+ return join(this.dataDir, "instances", name);
161
+ }
162
+ /** Get the adapter bound to an instance, falling back to primary adapter */
163
+ getAdapterForInstance(name) {
164
+ const worldId = this.instanceWorldBinding.get(name);
165
+ if (worldId)
166
+ return this.worlds.get(worldId)?.adapter ?? this.adapter;
167
+ return this.adapter;
168
+ }
169
+ /** Get the world for an instance */
170
+ getWorldForInstance(name) {
171
+ const worldId = this.instanceWorldBinding.get(name);
172
+ return worldId ? this.worlds.get(worldId) : this.worlds.values().next().value;
173
+ }
174
+ /** Get channel config for a specific adapter (by id), falling back to primary */
175
+ getChannelConfig(adapterId) {
176
+ if (adapterId) {
177
+ const world = this.worlds.get(adapterId);
178
+ if (world)
179
+ return world.channelConfig;
180
+ }
181
+ return this.fleetConfig?.channel;
182
+ }
183
+ /** Get the group_id for an instance's bound adapter */
184
+ getGroupIdForInstance(name) {
185
+ const world = this.getWorldForInstance(name);
186
+ return world?.groupId ?? String(this.fleetConfig?.channel?.group_id ?? "");
187
+ }
188
+ /** Bind an instance to a specific world. fromInbound=true skips general_topic to prevent overwrite. */
189
+ bindInstanceAdapter(name, adapterId, fromInbound = false) {
190
+ if (fromInbound && this.fleetConfig?.instances[name]?.general_topic)
191
+ return;
192
+ this.instanceWorldBinding.set(name, adapterId);
193
+ }
194
+ getInstanceStatus(name) {
195
+ const pidPath = join(this.getInstanceDir(name), "daemon.pid");
196
+ if (!existsSync(pidPath))
197
+ return "stopped";
198
+ const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
199
+ try {
200
+ process.kill(pid, 0);
201
+ return "running";
202
+ }
203
+ catch {
204
+ return "crashed";
205
+ }
206
+ }
207
+ async startInstance(name, config, topicMode) {
208
+ if (config.general_topic) {
209
+ this.ensureGeneralInstructions(config.working_directory, config.backend);
210
+ }
211
+ await this.lifecycle.start(name, config, topicMode);
212
+ // Auto-connect IPC — daemon.start() ensures socket is ready before resolving
213
+ await this.connectIpcToInstance(name);
214
+ }
215
+ /**
216
+ * Start instances with configurable concurrency and stagger delay.
217
+ * Instances sharing the same working_directory are serialized within a group
218
+ * to avoid config file races. Stagger delay is group-to-group, not instance-to-instance.
219
+ * TODO: per-instance startup timeout (existing issue, not introduced here)
220
+ */
221
+ async startInstancesWithConcurrency(entries, topicMode) {
222
+ const raw = this.fleetConfig?.defaults?.startup;
223
+ const concurrency = Math.max(1, Math.min(20, raw?.concurrency ?? 10));
224
+ const staggerMs = Math.max(0, Math.min(30_000, raw?.stagger_delay_ms ?? 500));
225
+ const byWorkDir = new Map();
226
+ for (const [name, config] of entries) {
227
+ const dir = config.working_directory;
228
+ if (!byWorkDir.has(dir))
229
+ byWorkDir.set(dir, []);
230
+ byWorkDir.get(dir).push([name, config]);
231
+ }
232
+ const groups = [...byWorkDir.values()];
233
+ let running = 0;
234
+ let idx = 0;
235
+ let lastStartAt = 0;
236
+ let pendingTimer = false;
237
+ await new Promise((resolve) => {
238
+ if (groups.length === 0) {
239
+ resolve();
240
+ return;
241
+ }
242
+ const startNext = () => {
243
+ if (pendingTimer)
244
+ return;
245
+ while (running < concurrency && idx < groups.length) {
246
+ const now = Date.now();
247
+ const elapsed = now - lastStartAt;
248
+ if (lastStartAt > 0 && elapsed < staggerMs) {
249
+ pendingTimer = true;
250
+ setTimeout(() => { pendingTimer = false; startNext(); }, staggerMs - elapsed);
251
+ return;
252
+ }
253
+ const group = groups[idx++];
254
+ running++;
255
+ lastStartAt = Date.now();
256
+ (async () => {
257
+ for (const [name, config] of group) {
258
+ await this.startInstance(name, config, topicMode).catch((err) => this.logger.error({ err, name }, "Failed to start instance"));
259
+ }
260
+ })().finally(() => {
261
+ running--;
262
+ if (idx >= groups.length && running === 0)
263
+ resolve();
264
+ else
265
+ startNext();
266
+ });
267
+ }
268
+ };
269
+ startNext();
270
+ });
271
+ }
272
+ async stopInstance(name) {
273
+ this.failoverActive.delete(name);
274
+ return this.lifecycle.stop(name);
275
+ }
276
+ /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
277
+ async restartSingleInstance(name) {
278
+ if (this.configPath) {
279
+ this.loadConfig(this.configPath);
280
+ this.routing.rebuild(this.fleetConfig);
281
+ this.reregisterClassicChannels();
282
+ }
283
+ const config = this.fleetConfig?.instances[name];
284
+ if (!config)
285
+ throw new Error(`Instance not found: ${name}`);
286
+ await this.stopInstance(name);
287
+ const topicMode = this.fleetConfig?.channel?.mode === "topic";
288
+ await this.startInstance(name, config, topicMode ?? false);
289
+ }
290
+ /** Load .env file from data dir into process.env */
291
+ loadEnvFile() {
292
+ const envPath = join(this.dataDir, ".env");
293
+ if (!existsSync(envPath))
294
+ return;
295
+ const content = readFileSync(envPath, "utf-8");
296
+ for (const line of content.split("\n")) {
297
+ const trimmed = line.trim();
298
+ if (!trimmed || trimmed.startsWith("#"))
299
+ continue;
300
+ const eqIdx = trimmed.indexOf("=");
301
+ if (eqIdx < 0)
302
+ continue;
303
+ const key = trimmed.slice(0, eqIdx);
304
+ const raw = trimmed.slice(eqIdx + 1);
305
+ const value = raw.replace(/^["'](.*)["']$/, '$1');
306
+ // .env file always wins over inherited shell env vars, so that
307
+ // quickstart's newly written token overrides any stale value.
308
+ process.env[key] = value;
309
+ }
310
+ }
311
+ /** Start all instances from fleet config */
312
+ async startAll(configPath) {
313
+ this.configPath = configPath;
314
+ this.loadEnvFile();
315
+ // Rotate fleet.log if oversized (before any logging)
316
+ const { rotateLogIfNeeded } = await import("./logger.js");
317
+ rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
318
+ const fleet = this.loadConfig(configPath);
319
+ const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
320
+ // Set tmux socket isolation for custom AGEND_HOME
321
+ const { getTmuxSocketName: getSocket } = await import("./paths.js");
322
+ TmuxManager.setSocketName(getSocket());
323
+ await TmuxManager.ensureSession(getTmuxSession());
324
+ // Start tmux control mode client for idle detection
325
+ if (!this.controlClient) {
326
+ this.controlClient = new TmuxControlClient(getTmuxSession(), 2000, this.logger);
327
+ this.controlClient.start();
328
+ }
329
+ // Stop any running daemons first (their health checks would respawn killed windows)
330
+ for (const [name] of this.daemons) {
331
+ await this.stopInstance(name);
332
+ }
333
+ // Then kill all remaining agend instance windows to prevent orphans.
334
+ // Kill both known instance windows (stale from previous run) and orphaned
335
+ // windows from deleted instances that are no longer in fleet.yaml.
336
+ const agendNames = new Set(Object.keys(fleet.instances));
337
+ agendNames.add("general");
338
+ try {
339
+ const existingWindows = await TmuxManager.listWindows(getTmuxSession());
340
+ for (const w of existingWindows) {
341
+ // Kill known instance windows (will be recreated)
342
+ // Also kill orphaned windows: any window with a topic ID suffix (name-tNNNNN)
343
+ // that isn't in the current config — these are leftovers from deleted instances
344
+ const isKnownInstance = agendNames.has(w.name);
345
+ const isOrphanedInstance = !isKnownInstance && (/-t\d+$/.test(w.name) || /^classic-/.test(w.name));
346
+ if (isKnownInstance || isOrphanedInstance) {
347
+ if (isOrphanedInstance)
348
+ this.logger.info({ window: w.name }, "Cleaning up orphaned tmux window");
349
+ const tm = new TmuxManager(getTmuxSession(), w.id);
350
+ await tm.killWindow();
351
+ }
352
+ }
353
+ }
354
+ catch (err) {
355
+ this.logger.debug({ err }, "Startup tmux window cleanup failed (best effort)");
356
+ }
357
+ const pidPath = join(this.dataDir, "fleet.pid");
358
+ writeFileSync(pidPath, String(process.pid), "utf-8");
359
+ this.eventLog = new EventLog(join(this.dataDir, "events.db"));
360
+ // Initialize classic channel manager and register existing channels in routing
361
+ this.classicChannels = new ClassicChannelManager(this.dataDir, this.logger);
362
+ for (const ch of this.classicChannels.getAll()) {
363
+ this.routing.register(ch.channelId, { kind: "classic", name: ch.instanceName });
364
+ }
365
+ // Poll classicBot.yaml for external changes every 30s
366
+ this.classicReloadTimer = setInterval(async () => {
367
+ try {
368
+ if (!this.classicChannels)
369
+ return;
370
+ const fleetBackend = this.fleetConfig?.defaults?.backend;
371
+ const oldBackends = new Map();
372
+ for (const ch of this.classicChannels.getAll()) {
373
+ oldBackends.set(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend));
374
+ }
375
+ if (!this.classicChannels.checkReload())
376
+ return;
377
+ this.reregisterClassicChannels();
378
+ for (const ch of this.classicChannels.getAll()) {
379
+ const newBackend = this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend);
380
+ if (this.daemons.has(ch.instanceName) && oldBackends.get(ch.instanceName) !== newBackend) {
381
+ this.logger.info({ instanceName: ch.instanceName, from: oldBackends.get(ch.instanceName), to: newBackend }, "Backend changed — restarting");
382
+ await this.stopInstance(ch.instanceName).catch(() => { });
383
+ // Small delay to let tmux window clean up
384
+ await new Promise(r => setTimeout(r, 2000));
385
+ await this.startClassicInstance(ch.instanceName, newBackend).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to restart classic instance"));
386
+ }
387
+ }
388
+ }
389
+ catch (err) {
390
+ this.logger.warn({ err }, "classicBot.yaml reload error");
391
+ }
392
+ }, 30_000);
393
+ const costGuardConfig = {
394
+ ...DEFAULT_COST_GUARD,
395
+ ...fleet.defaults.cost_guard,
396
+ };
397
+ this.costGuard = new CostGuard(costGuardConfig, this.eventLog);
398
+ this.costGuard.startMidnightReset();
399
+ const webhookConfigs = fleet.defaults.webhooks ?? [];
400
+ if (webhookConfigs.length > 0) {
401
+ this.webhookEmitter = new WebhookEmitter(webhookConfigs, this.logger);
402
+ this.logger.info({ count: webhookConfigs.length }, "Webhook emitter initialized");
403
+ }
404
+ this.costGuard.on("warn", safeHandler((instance, totalCents, limitCents) => {
405
+ this.notifyInstanceTopic(instance, `⚠️ ${instance} cost: ${formatCents(totalCents)} / ${formatCents(limitCents)} (${Math.round(totalCents / limitCents * 100)}%)`);
406
+ this.webhookEmitter?.emit("cost_warning", instance, { cost_cents: totalCents, limit_cents: limitCents });
407
+ }, this.logger, "costGuard.warn"));
408
+ this.costGuard.on("limit", safeHandler(async (instance, totalCents, limitCents) => {
409
+ this.notifyInstanceTopic(instance, `🛑 ${instance} daily limit ${formatCents(limitCents)} reached — pausing instance.`);
410
+ this.eventLog?.insert(instance, "instance_paused", { reason: "cost_limit", cost_cents: totalCents });
411
+ this.webhookEmitter?.emit("cost_limit", instance, { cost_cents: totalCents, limit_cents: limitCents });
412
+ await this.stopInstance(instance);
413
+ }, this.logger, "costGuard.limit"));
414
+ const summaryConfig = {
415
+ ...DEFAULT_DAILY_SUMMARY,
416
+ ...fleet.defaults.daily_summary,
417
+ };
418
+ this.dailySummary = new DailySummary(summaryConfig, costGuardConfig.timezone, (text) => {
419
+ if (!this.adapter || !this.fleetConfig?.channel?.group_id)
420
+ return;
421
+ this.adapter.sendText(String(this.fleetConfig.channel.group_id), text)
422
+ .catch(e => this.logger.warn({ err: e }, "Failed to send daily summary"));
423
+ // Rotate classic channel chat logs daily
424
+ this.classicChannels?.rotateLogs();
425
+ }, () => {
426
+ const instances = Object.keys(this.fleetConfig?.instances ?? {});
427
+ const costMap = new Map();
428
+ for (const name of instances) {
429
+ costMap.set(name, this.costGuard?.getDailyCostCents(name) ?? 0);
430
+ }
431
+ return DailySummary.generateText(this.eventLog, instances, costMap, this.costGuard?.getFleetTotalCents() ?? 0);
432
+ });
433
+ this.dailySummary.start();
434
+ // Rotate classic channel chat logs daily (piggyback on daily summary timer)
435
+ this.classicChannels?.rotateLogs();
436
+ // Auto-create general instance(s) — one per adapter when multi-channel
437
+ const hasGeneralTopic = Object.values(fleet.instances).some(inst => inst.general_topic === true);
438
+ if (!hasGeneralTopic) {
439
+ const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
440
+ const needsSuffix = channelConfigs.length > 1;
441
+ for (const ch of channelConfigs) {
442
+ const name = needsSuffix ? `general-${ch.id ?? ch.type}` : "general";
443
+ if (fleet.instances[name])
444
+ continue;
445
+ this.logger.info({ name }, "Auto-creating general instance");
446
+ const generalDir = join(getAgendHome(), name);
447
+ mkdirSync(generalDir, { recursive: true });
448
+ const backendName = fleet.defaults.backend ?? "claude-code";
449
+ this.ensureGeneralInstructions(generalDir, backendName);
450
+ fleet.instances[name] = {
451
+ ...DEFAULT_INSTANCE_CONFIG,
452
+ working_directory: generalDir,
453
+ general_topic: true,
454
+ };
455
+ }
456
+ this.saveFleetConfig();
457
+ }
458
+ if (topicMode && (fleet.channel || fleet.channels?.length)) {
459
+ const schedulerConfig = {
460
+ ...DEFAULT_SCHEDULER_CONFIG,
461
+ ...this.fleetConfig?.defaults.scheduler,
462
+ };
463
+ this.scheduler = new Scheduler(join(this.dataDir, "scheduler.db"), (schedule) => this.handleScheduleTrigger(schedule), schedulerConfig, (name) => this.fleetConfig?.instances?.[name] != null || !!this.classicChannels?.getAll().some(ch => ch.instanceName === name));
464
+ this.scheduler.init();
465
+ this.logger.info("Scheduler initialized");
466
+ // Inject active decisions as env var for MCP instructions.
467
+ // Snapshotted at startup — new decisions via post_decision are available
468
+ // through list_decisions tool but not auto-injected until restart.
469
+ try {
470
+ const decisions = this.scheduler.db.listDecisions("", { includeArchived: false });
471
+ if (decisions.length > 0) {
472
+ const capped = decisions.slice(0, 20).map(d => ({ title: d.title, content: (d.content ?? "").slice(0, 200) }));
473
+ process.env.AGEND_DECISIONS = JSON.stringify(capped);
474
+ this.logger.info({ count: decisions.length, injected: capped.length }, "Injected active decisions into env");
475
+ }
476
+ }
477
+ catch (err) {
478
+ this.logger.debug({ err }, "Decision injection skipped (no decisions db or query failed)");
479
+ }
480
+ }
481
+ await this.startInstancesWithConcurrency(Object.entries(fleet.instances), topicMode);
482
+ if (topicMode && (fleet.channel || fleet.channels?.length)) {
483
+ try {
484
+ await this.startSharedAdapter(fleet);
485
+ }
486
+ catch (err) {
487
+ this.logger.error({ err }, "startSharedAdapter failed — fleet continues without some adapters");
488
+ }
489
+ // Pre-bind general instances to their corresponding adapter
490
+ for (const [name, config] of Object.entries(fleet.instances)) {
491
+ if (!config.general_topic)
492
+ continue;
493
+ const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
494
+ for (const ch of channelConfigs) {
495
+ const id = ch.id ?? ch.type;
496
+ if (name.includes(id)) {
497
+ this.bindInstanceAdapter(name, id);
498
+ break;
499
+ }
500
+ }
501
+ }
502
+ // Auto-create topics AFTER adapter is ready (needs adapter.createTopic)
503
+ await this.topicCommands.autoCreateTopics();
504
+ const routeSummary = this.routing.rebuild(this.fleetConfig);
505
+ this.reregisterClassicChannels();
506
+ this.logger.info(`Routes: ${routeSummary}`);
507
+ // Resolve topic icon emoji IDs and start idle archive poller
508
+ await this.resolveTopicIcons();
509
+ this.topicArchiver.startPoller();
510
+ // IPC is already wired by startInstancesWithConcurrency → startInstance →
511
+ // connectIpcToInstance. The previous 3s sleep + connectToInstances loop
512
+ // was redundant.
513
+ // Start classic channel instances (parallel, concurrency 3)
514
+ if (this.classicChannels) {
515
+ const fleetBackend = this.fleetConfig?.defaults?.backend;
516
+ const channels = this.classicChannels.getAll();
517
+ const concurrency = 3;
518
+ let idx = 0;
519
+ while (idx < channels.length) {
520
+ const batch = channels.slice(idx, idx + concurrency);
521
+ await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
522
+ idx += concurrency;
523
+ }
524
+ }
525
+ for (const name of Object.keys(fleet.instances)) {
526
+ this.startStatuslineWatcher(name);
527
+ }
528
+ // Notify General topic that fleet is up
529
+ const classicCount = this.classicChannels?.getAll().length ?? 0;
530
+ const total = Object.keys(fleet.instances).length + classicCount;
531
+ const started = this.daemons.size;
532
+ const failedNames = Object.keys(fleet.instances).filter(n => !this.daemons.has(n));
533
+ const generalName = this.findGeneralInstance();
534
+ const generalThreadId = generalName ? fleet.instances[generalName]?.topic_id : undefined;
535
+ if (this.adapter && fleet.channel?.group_id) {
536
+ const text = failedNames.length === 0
537
+ ? `Fleet ready. ${started}/${total} instances running.`
538
+ : `Fleet ready. ${started}/${total} instances running. Failed: ${failedNames.join(", ")}`;
539
+ this.adapter.sendText(String(fleet.channel.group_id), text, {
540
+ threadId: generalThreadId != null ? String(generalThreadId) : undefined,
541
+ }).catch(e => this.logger.warn({ err: e }, "Failed to send fleet start notification"));
542
+ }
543
+ }
544
+ // Health HTTP endpoint
545
+ this.startHealthServer(fleet.health_port ?? 19280);
546
+ // SIGHUP: hot-reload instance config (add/remove/restart instances)
547
+ const onSighup = () => {
548
+ this.logger.info("Received SIGHUP, hot-reloading config...");
549
+ this.reconcileInstances()
550
+ .catch(err => this.logger.error({ err }, "SIGHUP config reload failed"));
551
+ process.once("SIGHUP", onSighup);
552
+ };
553
+ process.once("SIGHUP", onSighup);
554
+ const onRestart = () => {
555
+ this.logger.info("Received SIGUSR2, initiating graceful restart...");
556
+ this.restartInstances()
557
+ .catch(err => this.logger.error({ err }, "Graceful restart failed"))
558
+ .finally(() => process.once("SIGUSR2", onRestart));
559
+ };
560
+ process.once("SIGUSR2", onRestart);
561
+ // SIGUSR1: full process reload (graceful stop → exit → CLI restarts)
562
+ const onFullRestart = () => {
563
+ this.logger.info("Received SIGUSR1, initiating full restart (process reload)...");
564
+ this.gracefulShutdownForReload()
565
+ .then(() => {
566
+ this.logger.info("Full restart: shutdown complete, exiting for reload");
567
+ process.exit(0);
568
+ })
569
+ .catch(err => {
570
+ this.logger.error({ err }, "Full restart: graceful shutdown failed");
571
+ process.exit(1);
572
+ });
573
+ };
574
+ process.once("SIGUSR1", onFullRestart);
575
+ }
576
+ /** Start the shared channel adapter(s) for topic mode */
577
+ async startSharedAdapter(fleet) {
578
+ const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
579
+ if (channelConfigs.length === 0)
580
+ return;
581
+ // Start primary adapter (first channel) — this.adapter for backward compat
582
+ await this.startSingleAdapter(fleet, channelConfigs[0]);
583
+ // Start additional adapters
584
+ for (let i = 1; i < channelConfigs.length; i++) {
585
+ await this.startAdditionalAdapter(channelConfigs[i]);
586
+ }
587
+ }
588
+ /** Start the primary adapter (backward-compatible, sets this.adapter) */
589
+ async startSingleAdapter(fleet, channelConfig) {
590
+ const botToken = process.env[channelConfig.bot_token_env];
591
+ if (!botToken) {
592
+ this.logger.warn({ env: channelConfig.bot_token_env }, "Bot token env not set, skipping shared adapter");
593
+ return;
594
+ }
595
+ const accessDir = join(this.dataDir, "access");
596
+ mkdirSync(accessDir, { recursive: true });
597
+ const accessManager = new AccessManager(channelConfig.access, join(accessDir, "access.json"));
598
+ this.accessManager = accessManager;
599
+ const inboxDir = join(this.dataDir, "inbox");
600
+ mkdirSync(inboxDir, { recursive: true });
601
+ const adapterId = channelConfig.id ?? channelConfig.type;
602
+ this.adapter = await createAdapter(channelConfig, {
603
+ id: adapterId,
604
+ botToken,
605
+ accessManager,
606
+ inboxDir,
607
+ });
608
+ const world = new AdapterWorld(adapterId, this.adapter, accessManager, channelConfig);
609
+ this.worlds.set(adapterId, world);
610
+ this.adapters.set(adapterId, this.adapter);
611
+ this.adapter.on("message", safeHandler(async (msg) => {
612
+ await this.handleInboundMessage(msg);
613
+ }, this.logger, "adapter.message"));
614
+ this.adapter.on("callback_query", safeHandler(async (data) => {
615
+ if (data.callbackData.startsWith("hang:")) {
616
+ const parts = data.callbackData.split(":");
617
+ const action = parts[1];
618
+ const instanceName = parts[2];
619
+ if (action === "restart") {
620
+ await this.stopInstance(instanceName);
621
+ const config = this.fleetConfig?.instances[instanceName];
622
+ if (config) {
623
+ const topicMode = this.fleetConfig?.channel?.mode === "topic";
624
+ await this.startInstance(instanceName, config, topicMode);
625
+ // startInstance already calls connectIpcToInstance
626
+ }
627
+ this.adapter?.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`).catch(() => { });
628
+ }
629
+ else {
630
+ this.adapter?.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`).catch(() => { });
631
+ }
632
+ return;
633
+ }
634
+ }, this.logger, "adapter.callback_query"));
635
+ this.adapter.on("topic_closed", safeHandler(async (data) => {
636
+ // Skip unbind if we archived this topic ourselves
637
+ if (this.topicArchiver.isArchived(data.threadId))
638
+ return;
639
+ await this.topicCommands.handleTopicDeleted(data.threadId);
640
+ }, this.logger, "adapter.topic_closed"));
641
+ // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
642
+ this.adapter.on("slash_command", safeHandler(async (data) => {
643
+ if (data.command === "start") {
644
+ const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId);
645
+ await data.respond(reply);
646
+ }
647
+ else if (data.command === "stop") {
648
+ const reply = await this.handleClassicStop(data.channelId);
649
+ await data.respond(reply);
650
+ }
651
+ else if (data.command === "chat") {
652
+ const text = data.text ?? "";
653
+ if (!text) {
654
+ await data.respond("Usage: `/chat <message>`");
655
+ return;
656
+ }
657
+ const target = this.routing.resolve(data.channelId);
658
+ if (!target || target.kind !== "classic") {
659
+ await data.respond("No active agent in this channel. Use `/start` first.");
660
+ return;
661
+ }
662
+ const replyMsgId = await data.respond("👀");
663
+ const username = data.username ?? data.userId;
664
+ ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date());
665
+ await this.forwardToClassicInstance(target.name, text, {
666
+ chatId: data.channelId,
667
+ threadId: data.channelId,
668
+ messageId: replyMsgId ?? "",
669
+ userId: data.userId,
670
+ username,
671
+ source: "discord",
672
+ timestamp: new Date(),
673
+ });
674
+ }
675
+ else if (data.command === "compact" || data.command === "save" || data.command === "load") {
676
+ if (!this.classicChannels?.isAdmin(data.userId)) {
677
+ await data.respond("⛔ This command requires admin access.");
678
+ return;
679
+ }
680
+ const target = this.routing.resolve(data.channelId);
681
+ if (!target || target.kind !== "classic") {
682
+ await data.respond("No active agent in this channel. Use `/start` first.");
683
+ return;
684
+ }
685
+ let rawCmd;
686
+ if (data.command === "compact") {
687
+ rawCmd = "/compact";
688
+ }
689
+ else if (data.command === "save") {
690
+ const filename = data.options?.filename;
691
+ if (!/^[\w.-]+$/.test(filename)) {
692
+ await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
693
+ return;
694
+ }
695
+ rawCmd = data.options?.force ? `/chat save ${filename} -f` : `/chat save ${filename}`;
696
+ }
697
+ else {
698
+ const filename = data.options?.filename;
699
+ if (!/^[\w.-]+$/.test(filename)) {
700
+ await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
701
+ return;
702
+ }
703
+ rawCmd = `/chat load ${filename}`;
704
+ }
705
+ this.pasteRawToClassicInstance(target.name, rawCmd);
706
+ await data.respond(`✅ Sent \`${rawCmd}\` to ${target.name}`);
707
+ }
708
+ else if (data.command === "ctx") {
709
+ const target = this.routing.resolve(data.channelId);
710
+ if (!target) {
711
+ await data.respond("No active agent in this channel.");
712
+ return;
713
+ }
714
+ const instanceName = target.name;
715
+ const backend = target.kind === "classic"
716
+ ? (this.classicChannels?.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend) ?? "claude-code")
717
+ : (this.fleetConfig?.instances[instanceName]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
718
+ let context = null;
719
+ // Try statusline.json first
720
+ try {
721
+ const statusFile = join(this.getInstanceDir(instanceName), "statusline.json");
722
+ if (existsSync(statusFile)) {
723
+ const d = JSON.parse(readFileSync(statusFile, "utf-8"));
724
+ context = d.context_window?.used_percentage ?? null;
725
+ }
726
+ }
727
+ catch { /* ignore */ }
728
+ // Fallback: capture tmux pane
729
+ if (context == null) {
730
+ try {
731
+ const { execFileSync } = await import("node:child_process");
732
+ const { getTmuxSocketName } = await import("./paths.js");
733
+ const socketName = getTmuxSocketName();
734
+ const tmuxArgs = socketName
735
+ ? ["-L", socketName, "capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"]
736
+ : ["capture-pane", "-t", `${getTmuxSession()}:${instanceName}`, "-p"];
737
+ const pane = execFileSync("tmux", tmuxArgs, { encoding: "utf-8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] });
738
+ const m = pane.match(/(\d+)%.*!>/m) || pane.match(/◔\s*(\d+)%/);
739
+ if (m)
740
+ context = parseInt(m[1], 10);
741
+ }
742
+ catch { /* ignore */ }
743
+ }
744
+ if (context != null) {
745
+ await data.respond(`📊 Context: ${context}% used\nBackend: ${backend}\nInstance: ${instanceName}`);
746
+ }
747
+ else {
748
+ await data.respond(`Context info not available yet.\nBackend: ${backend}\nInstance: ${instanceName}`);
749
+ }
750
+ }
751
+ else if (data.command === "collab") {
752
+ if (!this.classicChannels?.isAdmin(data.userId)) {
753
+ await data.respond("⛔ This command requires admin access.");
754
+ return;
755
+ }
756
+ if (!this.classicChannels.isClassicChannel(data.channelId)) {
757
+ await data.respond("No active agent in this channel. Use `/start` first.");
758
+ return;
759
+ }
760
+ const newState = this.classicChannels.toggleCollab(data.channelId);
761
+ await data.respond(newState
762
+ ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible."
763
+ : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent.");
764
+ }
765
+ }, this.logger, "adapter.slash_command"));
766
+ await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
767
+ await this.adapter.start();
768
+ if (fleet.channel?.group_id) {
769
+ this.adapter.setChatId(String(fleet.channel.group_id));
770
+ }
771
+ this.adapter.on("started", safeHandler((username, userId) => {
772
+ this.logger.info(`Bot @${username} polling started. Ensure no other service is polling this bot token.`);
773
+ if (userId) {
774
+ this.botUserId = userId;
775
+ const w = this.worlds.values().next().value;
776
+ if (w)
777
+ w.botUserId = userId;
778
+ }
779
+ }, this.logger, "adapter.started"));
780
+ this.adapter.on("polling_conflict", safeHandler(({ attempt, delay }) => {
781
+ this.logger.warn(`409 Conflict (attempt ${attempt}), retry in ${delay / 1000}s`);
782
+ }, this.logger, "adapter.polling_conflict"));
783
+ this.adapter.on("handler_error", safeHandler((err) => {
784
+ this.logger.warn({ err: err instanceof Error ? err.message : String(err) }, "Adapter handler error");
785
+ }, this.logger, "adapter.handler_error"));
786
+ this.adapter.on("new_group_detected", safeHandler((data) => {
787
+ const adminMsg = `🆕 Bot added to new server:\n• Name: ${data.groupTitle}\n• ID: ${data.groupId}\n• Platform: ${data.source}\n\nTo allow: add \`${data.groupId}\` to classicBot.yaml \`allowed_guilds\``;
788
+ const generalId = this.findGeneralInstance();
789
+ if (generalId)
790
+ this.notifyInstanceTopic(generalId, adminMsg);
791
+ }, this.logger, "adapter.new_group_detected"));
792
+ this.startTopicCleanupPoller();
793
+ // Prune stale external sessions every 5 minutes
794
+ this.sessionPruneTimer = setInterval(() => {
795
+ this.pruneStaleExternalSessions().catch(err => this.logger.debug({ err }, "Session prune failed"));
796
+ }, 5 * 60 * 1000);
797
+ }
798
+ /** Start an additional (non-primary) adapter */
799
+ async startAdditionalAdapter(channelConfig) {
800
+ const adapterId = channelConfig.id ?? channelConfig.type;
801
+ const botToken = process.env[channelConfig.bot_token_env];
802
+ if (!botToken) {
803
+ this.logger.warn({ env: channelConfig.bot_token_env, adapterId }, "Bot token env not set, skipping adapter");
804
+ return;
805
+ }
806
+ const accessDir = join(this.dataDir, "access");
807
+ mkdirSync(accessDir, { recursive: true });
808
+ const accessManager = new AccessManager(channelConfig.access, join(accessDir, `access-${adapterId}.json`));
809
+ const inboxDir = join(this.dataDir, "inbox");
810
+ mkdirSync(inboxDir, { recursive: true });
811
+ const adapter = await createAdapter(channelConfig, {
812
+ id: adapterId,
813
+ botToken,
814
+ accessManager,
815
+ inboxDir,
816
+ });
817
+ const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
818
+ this.worlds.set(adapterId, world);
819
+ this.adapters.set(adapterId, adapter);
820
+ // Wire up event handlers (same as primary, routes through shared handleInboundMessage)
821
+ adapter.on("message", safeHandler(async (msg) => {
822
+ await this.handleInboundMessage(msg);
823
+ }, this.logger, `adapter[${adapterId}].message`));
824
+ adapter.on("callback_query", safeHandler(async (data) => {
825
+ if (data.callbackData.startsWith("hang:")) {
826
+ const parts = data.callbackData.split(":");
827
+ const action = parts[1];
828
+ const instanceName = parts[2];
829
+ if (action === "restart") {
830
+ await this.stopInstance(instanceName);
831
+ const config = this.fleetConfig?.instances[instanceName];
832
+ if (config) {
833
+ const topicMode = this.fleetConfig?.channel?.mode === "topic";
834
+ await this.startInstance(instanceName, config, topicMode);
835
+ }
836
+ adapter.editMessage(data.chatId, data.messageId, `🔄 ${instanceName} restarted.`).catch(() => { });
837
+ }
838
+ else {
839
+ adapter.editMessage(data.chatId, data.messageId, `⏳ Continuing to wait for ${instanceName}.`).catch(() => { });
840
+ }
841
+ }
842
+ }, this.logger, `adapter[${adapterId}].callback_query`));
843
+ adapter.on("topic_closed", safeHandler(async (data) => {
844
+ if (this.topicArchiver.isArchived(data.threadId))
845
+ return;
846
+ await this.topicCommands.handleTopicDeleted(data.threadId);
847
+ }, this.logger, `adapter[${adapterId}].topic_closed`));
848
+ // Slash commands: classic bot + admin commands
849
+ adapter.on("slash_command", safeHandler(async (data) => {
850
+ if (data.command === "start") {
851
+ const reply = await this.handleClassicStart(data.channelId, data.channelName, data.userId, data.guildId);
852
+ await data.respond(reply);
853
+ }
854
+ else if (data.command === "stop") {
855
+ const reply = await this.handleClassicStop(data.channelId);
856
+ await data.respond(reply);
857
+ }
858
+ else if (data.command === "chat") {
859
+ const text = data.text ?? "";
860
+ if (!text) {
861
+ await data.respond("Usage: `/chat <message>`");
862
+ return;
863
+ }
864
+ const target = this.routing.resolve(data.channelId);
865
+ if (!target || target.kind !== "classic") {
866
+ await data.respond("No active agent in this channel. Use `/start` first.");
867
+ return;
868
+ }
869
+ const replyMsgId = await data.respond("👀");
870
+ const username = data.username ?? data.userId;
871
+ ClassicChannelManager.logMessage(target.name, username, `/chat ${text}`, new Date());
872
+ await this.forwardToClassicInstance(target.name, text, {
873
+ chatId: data.channelId,
874
+ threadId: data.channelId,
875
+ messageId: replyMsgId ?? "",
876
+ userId: data.userId,
877
+ username,
878
+ source: channelConfig.type,
879
+ timestamp: new Date(),
880
+ });
881
+ }
882
+ else if (data.command === "compact" || data.command === "save" || data.command === "load") {
883
+ if (!this.classicChannels?.isAdmin(data.userId)) {
884
+ await data.respond("⛔ This command requires admin access.");
885
+ return;
886
+ }
887
+ const target = this.routing.resolve(data.channelId);
888
+ if (!target || target.kind !== "classic") {
889
+ await data.respond("No active agent in this channel. Use `/start` first.");
890
+ return;
891
+ }
892
+ let rawCmd;
893
+ if (data.command === "compact") {
894
+ rawCmd = "/compact";
895
+ }
896
+ else if (data.command === "save") {
897
+ const filename = data.options?.filename;
898
+ if (!/^[\w.-]+$/.test(filename)) {
899
+ await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
900
+ return;
901
+ }
902
+ rawCmd = data.options?.force ? `/chat save ${filename} -f` : `/chat save ${filename}`;
903
+ }
904
+ else {
905
+ const filename = data.options?.filename;
906
+ if (!/^[\w.-]+$/.test(filename)) {
907
+ await data.respond("⛔ Invalid filename — only letters, numbers, dots, hyphens, underscores allowed.");
908
+ return;
909
+ }
910
+ rawCmd = `/chat load ${filename}`;
911
+ }
912
+ this.pasteRawToClassicInstance(target.name, rawCmd);
913
+ await data.respond(`✅ Sent \`${rawCmd}\` to ${target.name}`);
914
+ }
915
+ else if (data.command === "ctx") {
916
+ const target = this.routing.resolve(data.channelId);
917
+ if (!target) {
918
+ await data.respond("No active agent in this channel.");
919
+ return;
920
+ }
921
+ const instanceName = target.name;
922
+ const ctxBackend = target.kind === "classic"
923
+ ? (this.classicChannels?.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend) ?? "claude-code")
924
+ : (this.fleetConfig?.instances[instanceName]?.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
925
+ let context = null;
926
+ try {
927
+ const statusFile = join(this.getInstanceDir(instanceName), "statusline.json");
928
+ if (existsSync(statusFile)) {
929
+ const d = JSON.parse(readFileSync(statusFile, "utf-8"));
930
+ context = d.context_window?.used_percentage ?? null;
931
+ }
932
+ }
933
+ catch { /* ignore */ }
934
+ if (context != null) {
935
+ await data.respond(`📊 Context: ${context}% used\nBackend: ${ctxBackend}\nInstance: ${instanceName}`);
936
+ }
937
+ else {
938
+ await data.respond(`Context info not available yet.\nBackend: ${ctxBackend}\nInstance: ${instanceName}`);
939
+ }
940
+ }
941
+ else if (data.command === "collab") {
942
+ if (!this.classicChannels?.isAdmin(data.userId)) {
943
+ await data.respond("⛔ This command requires admin access.");
944
+ return;
945
+ }
946
+ if (!this.classicChannels.isClassicChannel(data.channelId)) {
947
+ await data.respond("No active agent in this channel. Use `/start` first.");
948
+ return;
949
+ }
950
+ const newState = this.classicChannels.toggleCollab(data.channelId);
951
+ await data.respond(newState
952
+ ? "🤝 Collaboration mode **ON** — @mention this bot to trigger the agent. Other bot messages are visible."
953
+ : "💬 Collaboration mode **OFF** — use `/chat` to talk to the agent.");
954
+ }
955
+ }, this.logger, `adapter[${adapterId}].slash_command`));
956
+ await adapter.start();
957
+ if (channelConfig.group_id) {
958
+ adapter.setChatId(String(channelConfig.group_id));
959
+ }
960
+ adapter.on("started", safeHandler((username, userId) => {
961
+ this.logger.info(`[${adapterId}] Bot @${username} polling started.`);
962
+ const world = this.worlds.get(adapterId);
963
+ if (world) {
964
+ world.botUsername = username;
965
+ if (userId)
966
+ world.botUserId = userId;
967
+ }
968
+ }, this.logger, `adapter[${adapterId}].started`));
969
+ adapter.on("new_group_detected", safeHandler((data) => {
970
+ const adminMsg = `🆕 Bot added to new server:\n• Name: ${data.groupTitle}\n• ID: ${data.groupId}\n• Platform: ${data.source}\n\nTo allow: add \`${data.groupId}\` to classicBot.yaml \`allowed_guilds\``;
971
+ const generalId = this.findGeneralInstance(adapterId);
972
+ if (generalId)
973
+ this.notifyInstanceTopic(generalId, adminMsg);
974
+ }, this.logger, `adapter[${adapterId}].new_group_detected`));
975
+ this.logger.info({ adapterId, type: channelConfig.type }, "Additional adapter started");
976
+ }
977
+ /** Connect IPC to a single instance with all handlers */
978
+ async connectIpcToInstance(name) {
979
+ // Close existing client to prevent socket leak on reconnect
980
+ const existing = this.instanceIpcClients.get(name);
981
+ if (existing) {
982
+ try {
983
+ existing.close();
984
+ }
985
+ catch (err) {
986
+ this.logger.debug({ err, name }, "IPC client close failed (likely already closed)");
987
+ }
988
+ this.instanceIpcClients.delete(name);
989
+ }
990
+ const sockPath = join(this.getInstanceDir(name), "channel.sock");
991
+ if (!existsSync(sockPath))
992
+ return;
993
+ const ipc = new IpcClient(sockPath);
994
+ try {
995
+ await ipc.connect();
996
+ this.instanceIpcClients.set(name, ipc);
997
+ ipc.on("message", safeHandler(async (msg) => {
998
+ if (msg.type === "mcp_ready") {
999
+ // Register external sessions (sessionName differs from instance name)
1000
+ const sessionName = msg.sessionName;
1001
+ if (sessionName && sessionName !== name) {
1002
+ this.sessionRegistry.set(sessionName, name);
1003
+ this.logger.info({ sessionName, instanceName: name }, "Registered external session");
1004
+ }
1005
+ }
1006
+ else if (msg.type === "session_disconnected") {
1007
+ const sessionName = msg.sessionName;
1008
+ if (sessionName && this.sessionRegistry.has(sessionName)) {
1009
+ this.sessionRegistry.delete(sessionName);
1010
+ this.logger.info({ sessionName, instanceName: name }, "Unregistered external session");
1011
+ }
1012
+ }
1013
+ else if (msg.type === "fleet_outbound") {
1014
+ // Auto-register external session on first outbound message — covers the
1015
+ // race where mcp_ready arrived before fleet manager connected and query_sessions
1016
+ // fired before the MCP server reconnected.
1017
+ const sender = msg.senderSessionName;
1018
+ if (sender && sender !== name && !this.sessionRegistry.has(sender)) {
1019
+ this.sessionRegistry.set(sender, name);
1020
+ this.logger.info({ sessionName: sender, instanceName: name }, "Registered external session");
1021
+ }
1022
+ await this.handleOutboundFromInstance(name, msg);
1023
+ }
1024
+ else if (msg.type === "fleet_tool_status") {
1025
+ this.handleToolStatusFromInstance(name, msg);
1026
+ }
1027
+ else if (msg.type === "fleet_schedule_create" || msg.type === "fleet_schedule_list" ||
1028
+ msg.type === "fleet_schedule_update" || msg.type === "fleet_schedule_delete") {
1029
+ this.handleScheduleCrud(name, msg);
1030
+ }
1031
+ else if (msg.type === "fleet_decision_create" || msg.type === "fleet_decision_list" ||
1032
+ msg.type === "fleet_decision_update") {
1033
+ this.handleDecisionCrud(name, msg);
1034
+ }
1035
+ else if (msg.type === "fleet_task") {
1036
+ this.handleTaskCrud(name, msg);
1037
+ }
1038
+ else if (msg.type === "fleet_set_display_name") {
1039
+ this.handleSetDisplayName(name, msg);
1040
+ }
1041
+ else if (msg.type === "fleet_set_description") {
1042
+ this.handleSetDescription(name, msg);
1043
+ }
1044
+ }, this.logger, `ipc.message[${name}]`));
1045
+ // Ask daemon for any sessions that registered before we connected
1046
+ // (fixes race condition where mcp_ready was broadcast before fleet manager connected)
1047
+ ipc.send({ type: "query_sessions" });
1048
+ this.logger.debug({ name }, "Connected to instance IPC");
1049
+ if (!this.statuslineWatcher.has(name)) {
1050
+ this.statuslineWatcher.watch(name);
1051
+ }
1052
+ }
1053
+ catch (err) {
1054
+ this.logger.warn({ name, err }, "Failed to connect to instance IPC");
1055
+ }
1056
+ }
1057
+ /** Handle inbound message — transcribe voice if present, then route */
1058
+ findGeneralInstance(adapterId) {
1059
+ if (!this.fleetConfig)
1060
+ return undefined;
1061
+ const generals = [];
1062
+ for (const [name, config] of Object.entries(this.fleetConfig.instances)) {
1063
+ if (config.general_topic === true && this.daemons.has(name)) {
1064
+ generals.push(name);
1065
+ }
1066
+ }
1067
+ if (generals.length === 0)
1068
+ return undefined;
1069
+ if (generals.length === 1)
1070
+ return generals[0];
1071
+ if (adapterId) {
1072
+ const match = generals.find(n => n.includes(adapterId));
1073
+ if (match)
1074
+ return match;
1075
+ }
1076
+ return generals[0];
1077
+ }
1078
+ async handleInboundMessage(msg) {
1079
+ const threadId = msg.threadId || undefined;
1080
+ // Bot messages: only allow in collab channels
1081
+ if (msg.isBotMessage) {
1082
+ if (!threadId)
1083
+ return;
1084
+ const target = this.routing.resolve(threadId);
1085
+ if (!target || target.kind !== "classic")
1086
+ return;
1087
+ if (!this.classicChannels?.isCollab(threadId))
1088
+ return;
1089
+ // Fall through to classic channel handling
1090
+ }
1091
+ // Access control — classic channels are open to all, others require allowed user
1092
+ const am = (msg.adapterId ? this.worlds.get(msg.adapterId)?.accessManager : undefined) ?? this.accessManager;
1093
+ if (am && !am.isAllowed(msg.userId)) {
1094
+ const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
1095
+ const isTelegramClassicCandidate = msg.source === "telegram" && msg.chatId !== adapterGroupId && !threadId;
1096
+ if (!isTelegramClassicCandidate) {
1097
+ const target = threadId ? this.routing.resolve(threadId) : undefined;
1098
+ this.logger.info({ userId: msg.userId, threadId, targetKind: target?.kind, targetName: target?.name }, "Access check for non-allowed user");
1099
+ if (!target || target.kind !== "classic")
1100
+ return;
1101
+ }
1102
+ }
1103
+ if (threadId == null) {
1104
+ // ── Telegram Classic Mode ──
1105
+ // Messages from chats other than the primary forum group are classic mode candidates.
1106
+ // Private chats (positive chatId) and regular groups (negative, not group_id) qualify.
1107
+ const adapterGroupId = String(this.getChannelConfig(msg.adapterId)?.group_id ?? "");
1108
+ const isTelegramClassic = msg.source === "telegram" && msg.chatId !== adapterGroupId;
1109
+ if (isTelegramClassic && this.classicChannels) {
1110
+ const chatId = msg.chatId;
1111
+ // Strip @BotUsername suffix from commands (e.g. /start@BotName → /start)
1112
+ let text = (msg.text ?? "").replace(/^(\/\w+)@\S+/, "$1");
1113
+ // Detect @OurBot mention (only our bot, not other bots)
1114
+ const world = this.worlds.get(msg.adapterId ?? "");
1115
+ const botUser = world?.botUsername;
1116
+ const isBotMentioned = !!(botUser && text.toLowerCase().includes(`@${botUser.toLowerCase()}`));
1117
+ const isPrivateChat = !chatId.startsWith("-"); // Telegram: positive = private, negative = group
1118
+ const msgAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1119
+ // Handle /start command
1120
+ if (text === "/start" || text.startsWith("/start ")) {
1121
+ if (isPrivateChat) {
1122
+ if (!this.classicChannels.isUserAllowed(msg.userId)) {
1123
+ await msgAdapter?.sendText(chatId, "⛔ You are not in the allowed users list.");
1124
+ return;
1125
+ }
1126
+ }
1127
+ else {
1128
+ if (!this.classicChannels.isGroupAllowed(chatId)) {
1129
+ // Notify admin about new group wanting access
1130
+ const groupTitle = msg.chatTitle || chatId;
1131
+ const adminMsg = `🆕 New group detected:\n• Name: ${groupTitle}\n• ID: ${chatId}\n• User: ${msg.username} (${msg.userId})\n• Platform: ${msg.source}\n\nTo allow: add \`${chatId}\` to classicBot.yaml \`allowed_guilds\``;
1132
+ const generalId = this.findGeneralInstance(msg.adapterId);
1133
+ if (generalId) {
1134
+ this.notifyInstanceTopic(generalId, adminMsg);
1135
+ }
1136
+ await msgAdapter?.sendText(chatId, "⏳ Access requested. Waiting for admin approval.");
1137
+ return;
1138
+ }
1139
+ }
1140
+ const channelName = msg.username || chatId;
1141
+ const reply = await this.handleClassicStart(chatId, channelName, msg.userId);
1142
+ if (msg.adapterId)
1143
+ this.bindInstanceAdapter(classicInstanceName(sanitizeInstanceName(channelName || chatId), chatId), msg.adapterId, true);
1144
+ await msgAdapter?.sendText(chatId, reply);
1145
+ return;
1146
+ }
1147
+ // Handle /stop command
1148
+ if (text === "/stop" || text.startsWith("/stop ")) {
1149
+ const reply = await this.handleClassicStop(chatId);
1150
+ await msgAdapter?.sendText(chatId, reply);
1151
+ return;
1152
+ }
1153
+ // Route to classic channel if registered
1154
+ const target = this.routing.resolve(chatId);
1155
+ if (target?.kind === "classic") {
1156
+ if (msg.adapterId)
1157
+ this.bindInstanceAdapter(target.name, msg.adapterId, true);
1158
+ // TG ClassicBot: only @mention triggers agent (both private and group).
1159
+ // /chat command is NOT supported for TG classic — use @bot instead.
1160
+ if (!isBotMentioned) {
1161
+ // No trigger: save attachments + react, log, but don't forward to agent
1162
+ const syntheticMsg = { ...msg, threadId: chatId, text: "" };
1163
+ await this.handleClassicChannelMessage(target.name, syntheticMsg);
1164
+ return;
1165
+ }
1166
+ // Strip @bot from text and forward as /chat
1167
+ const cleanText = botUser ? text.replace(new RegExp(`@${botUser}`, "gi"), "").trim() : text;
1168
+ const syntheticMsg = { ...msg, threadId: chatId, text: `/chat ${cleanText}` };
1169
+ await this.handleClassicChannelMessage(target.name, syntheticMsg);
1170
+ return;
1171
+ }
1172
+ // Handle @bot without active agent
1173
+ if (isBotMentioned) {
1174
+ await msgAdapter?.sendText(chatId, "No active agent. Use /start first.");
1175
+ return;
1176
+ }
1177
+ // Unregistered private chat: ignore (don't fall through to General)
1178
+ if (isPrivateChat)
1179
+ return;
1180
+ // Unregistered group: ignore
1181
+ return;
1182
+ }
1183
+ // General topic: check for /status command
1184
+ if (await this.topicCommands.handleGeneralCommand(msg))
1185
+ return;
1186
+ // Forward to General Topic instance if configured
1187
+ const generalInstance = this.findGeneralInstance(msg.adapterId);
1188
+ if (generalInstance) {
1189
+ this.warnIfRateLimited(generalInstance, msg);
1190
+ if (msg.adapterId)
1191
+ this.bindInstanceAdapter(generalInstance, msg.adapterId, true);
1192
+ const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1193
+ const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
1194
+ const ipc = this.instanceIpcClients.get(generalInstance);
1195
+ if (ipc) {
1196
+ if (msg.chatId && msg.messageId) {
1197
+ inboundAdapter.react(msg.chatId, msg.messageId, "👀")
1198
+ .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1199
+ }
1200
+ ipc.send({
1201
+ type: "fleet_inbound",
1202
+ content: text,
1203
+ targetSession: generalInstance,
1204
+ meta: {
1205
+ chat_id: msg.chatId,
1206
+ message_id: msg.messageId,
1207
+ user: msg.username,
1208
+ user_id: msg.userId,
1209
+ ts: msg.timestamp.toISOString(),
1210
+ thread_id: "",
1211
+ source: msg.source,
1212
+ ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
1213
+ ...extraMeta,
1214
+ },
1215
+ });
1216
+ this.lastInboundUser.set(generalInstance, msg.username);
1217
+ this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
1218
+ this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), generalInstance);
1219
+ this.emitSseEvent("message", {
1220
+ instance: generalInstance, sender: msg.username,
1221
+ text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
1222
+ });
1223
+ }
1224
+ }
1225
+ return;
1226
+ }
1227
+ const target = this.routing.resolve(threadId);
1228
+ if (!target) {
1229
+ // Suppress unbound topic message for Discord — regular text channels are expected
1230
+ // to be unbound (classic mode or user-created). Only show for Telegram forum topics.
1231
+ if (msg.source !== "discord") {
1232
+ this.topicCommands.handleUnboundTopic(msg);
1233
+ }
1234
+ return;
1235
+ }
1236
+ // Classic channel: log all messages, only forward /chat to agent
1237
+ if (target.kind === "classic") {
1238
+ if (msg.adapterId)
1239
+ this.bindInstanceAdapter(target.name, msg.adapterId, true);
1240
+ await this.handleClassicChannelMessage(target.name, msg);
1241
+ return;
1242
+ }
1243
+ const instanceName = target.name;
1244
+ // Intercept admin commands (/status, /restart, /sysinfo) in general topics
1245
+ const instanceConfig = this.fleetConfig?.instances[instanceName];
1246
+ if (instanceConfig?.general_topic && await this.topicCommands.handleGeneralCommand(msg)) {
1247
+ return;
1248
+ }
1249
+ // Intercept /ctx in any instance topic
1250
+ if (await this.topicCommands.handleInstanceCommand(msg, instanceName)) {
1251
+ return;
1252
+ }
1253
+ // Bind instance to the adapter that delivered this message
1254
+ if (msg.adapterId)
1255
+ this.bindInstanceAdapter(instanceName, msg.adapterId, true);
1256
+ // Reopen archived topic before routing
1257
+ if (this.topicArchiver.isArchived(threadId)) {
1258
+ await this.topicArchiver.reopen(threadId, instanceName);
1259
+ }
1260
+ this.touchActivity(instanceName);
1261
+ this.setTopicIcon(instanceName, "blue");
1262
+ this.warnIfRateLimited(instanceName, msg);
1263
+ const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1264
+ const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, instanceName);
1265
+ const ipc = this.instanceIpcClients.get(instanceName);
1266
+ if (!ipc) {
1267
+ this.logger.warn({ instanceName }, "No IPC connection to instance");
1268
+ return;
1269
+ }
1270
+ if (msg.chatId && msg.messageId) {
1271
+ inboundAdapter.react(msg.chatId, msg.messageId, "👀")
1272
+ .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
1273
+ }
1274
+ ipc.send({
1275
+ type: "fleet_inbound",
1276
+ content: text,
1277
+ targetSession: instanceName, // Channel messages → instance's own session
1278
+ meta: {
1279
+ chat_id: msg.chatId,
1280
+ message_id: msg.messageId,
1281
+ user: msg.username,
1282
+ user_id: msg.userId,
1283
+ ts: msg.timestamp.toISOString(),
1284
+ thread_id: msg.threadId ?? "",
1285
+ source: msg.source,
1286
+ ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
1287
+ ...extraMeta,
1288
+ },
1289
+ });
1290
+ this.lastInboundUser.set(instanceName, msg.username);
1291
+ this.logger.info(`${msg.username} → ${instanceName}: ${(text ?? "").slice(0, 100)}`);
1292
+ this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), instanceName);
1293
+ this.emitSseEvent("message", {
1294
+ instance: instanceName, sender: msg.username,
1295
+ text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
1296
+ });
1297
+ }
1298
+ /** Handle outbound tool calls from a daemon instance */
1299
+ /** Warn (but don't block) when rate limits are high. 30-min debounce per instance. */
1300
+ rateLimitWarnedAt = new Map();
1301
+ warnIfRateLimited(instanceName, msg) {
1302
+ const rl = this.statuslineWatcher.getRateLimits(instanceName);
1303
+ if (!rl)
1304
+ return;
1305
+ let warning = "";
1306
+ if (rl.five_hour_pct >= 95) {
1307
+ warning = `⚠️ ${instanceName} at ${Math.round(rl.five_hour_pct)}% of 5h rate limit. Responses may be slower.`;
1308
+ }
1309
+ else if (rl.seven_day_pct >= 95) {
1310
+ warning = `⚠️ ${instanceName} at ${Math.round(rl.seven_day_pct)}% weekly usage. Responses may be slower or fail.`;
1311
+ }
1312
+ if (!warning)
1313
+ return;
1314
+ const lastWarn = this.rateLimitWarnedAt.get(instanceName) ?? 0;
1315
+ if (Date.now() - lastWarn < 30 * 60_000)
1316
+ return;
1317
+ this.rateLimitWarnedAt.set(instanceName, Date.now());
1318
+ const warnAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
1319
+ if (warnAdapter && msg.chatId) {
1320
+ warnAdapter.sendText(msg.chatId, warning, { threadId: msg.threadId ?? undefined }).catch(() => { });
1321
+ }
1322
+ }
1323
+ /** Handle outbound tool calls from a daemon instance */
1324
+ async handleOutboundFromInstance(instanceName, msg) {
1325
+ if (this.worlds.size === 0)
1326
+ return;
1327
+ this.touchActivity(instanceName);
1328
+ this.setTopicIcon(instanceName, "green");
1329
+ const tool = msg.tool;
1330
+ const args = (msg.args ?? {});
1331
+ const requestId = msg.requestId;
1332
+ const fleetRequestId = msg.fleetRequestId;
1333
+ const senderSessionName = msg.senderSessionName;
1334
+ const respond = (result, error) => {
1335
+ const ipc = this.instanceIpcClients.get(instanceName);
1336
+ if (fleetRequestId) {
1337
+ ipc?.send({ type: "fleet_outbound_response", fleetRequestId, result, error });
1338
+ }
1339
+ else {
1340
+ ipc?.send({ type: "fleet_outbound_response", requestId, result, error });
1341
+ }
1342
+ };
1343
+ // Resolve threadId: use sender's topic_id if sender is a known fleet instance,
1344
+ // fall back to general topic if sender is unknown, or IPC owner if no sender.
1345
+ const senderInstanceName = senderSessionName && this.fleetConfig?.instances[senderSessionName]
1346
+ ? senderSessionName
1347
+ : null;
1348
+ const routingConfig = senderInstanceName
1349
+ ? this.fleetConfig?.instances[senderInstanceName]
1350
+ : (senderSessionName ? undefined : this.fleetConfig?.instances[instanceName]);
1351
+ const threadId = resolveReplyThreadId(args.thread_id, routingConfig);
1352
+ // Select adapter: use instance binding, or resolve from chatId in args
1353
+ const outAdapter = this.getAdapterForInstance(senderInstanceName ?? instanceName) ?? this.adapter;
1354
+ if (!outAdapter) {
1355
+ respond(null, "No adapter available");
1356
+ return;
1357
+ }
1358
+ // Route standard channel tools (reply, react, edit_message, download_attachment)
1359
+ if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
1360
+ if (tool === "reply") {
1361
+ const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
1362
+ this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
1363
+ this.emitSseEvent("message", {
1364
+ instance: instanceName, sender: senderSessionName ?? instanceName,
1365
+ text: (args.text ?? "").slice(0, 2000),
1366
+ ts: new Date().toISOString(),
1367
+ });
1368
+ }
1369
+ return;
1370
+ }
1371
+ // Log tool calls for activity visualization
1372
+ const senderLabel = senderSessionName ?? instanceName;
1373
+ this.eventLog?.logActivity("tool_call", senderLabel, this.summarizeToolCall(tool, args));
1374
+ // Dispatch fleet-specific tools via handler map
1375
+ const handler = outboundHandlers.get(tool);
1376
+ if (handler) {
1377
+ await handler(this, args, respond, { instanceName, requestId, fleetRequestId, senderSessionName });
1378
+ }
1379
+ else {
1380
+ respond(null, `Unknown tool: ${tool}`);
1381
+ }
1382
+ }
1383
+ /** Handle tool status update from a daemon instance */
1384
+ handleToolStatusFromInstance(instanceName, msg) {
1385
+ const statusAdapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
1386
+ if (!statusAdapter)
1387
+ return;
1388
+ const text = msg.text;
1389
+ const editMessageId = msg.editMessageId;
1390
+ const senderSessionName = msg.senderSessionName;
1391
+ const senderInstanceName = senderSessionName && this.fleetConfig?.instances[senderSessionName]
1392
+ ? senderSessionName
1393
+ : null;
1394
+ const routingConfig = senderInstanceName
1395
+ ? this.fleetConfig?.instances[senderInstanceName]
1396
+ : (senderSessionName ? undefined : this.fleetConfig?.instances[instanceName]);
1397
+ const threadId = routingConfig?.topic_id ? String(routingConfig.topic_id) : undefined;
1398
+ const chatId = statusAdapter.getChatId();
1399
+ if (!chatId)
1400
+ return;
1401
+ if (editMessageId) {
1402
+ statusAdapter.editMessage(chatId, editMessageId, text).catch(e => this.logger.debug({ err: e }, "Failed to edit tool status message"));
1403
+ }
1404
+ else {
1405
+ statusAdapter.sendText(chatId, text, { threadId }).then((sent) => {
1406
+ const ipc = this.instanceIpcClients.get(instanceName);
1407
+ ipc?.send({ type: "fleet_tool_status_ack", messageId: sent.messageId });
1408
+ }).catch(e => this.logger.warn({ err: e }, "Failed to send tool status message"));
1409
+ }
1410
+ }
1411
+ // ===================== Scheduler =====================
1412
+ async handleScheduleTrigger(schedule) {
1413
+ const { target, reply_chat_id, reply_thread_id, message, label, id, source } = schedule;
1414
+ const RATE_LIMIT_DEFER_THRESHOLD = 85;
1415
+ const rl = this.statuslineWatcher.getRateLimits(target);
1416
+ if (rl && rl.five_hour_pct > RATE_LIMIT_DEFER_THRESHOLD) {
1417
+ this.scheduler.recordRun(id, "deferred", `5hr rate limit at ${rl.five_hour_pct}%`);
1418
+ this.eventLog?.insert(target, "schedule_deferred", {
1419
+ schedule_id: id,
1420
+ label,
1421
+ five_hour_pct: rl.five_hour_pct,
1422
+ });
1423
+ this.webhookEmitter?.emit("schedule_deferred", target, { schedule_id: id, label, five_hour_pct: rl.five_hour_pct });
1424
+ this.notifyInstanceTopic(target, `⏳ Schedule "${label ?? id}" deferred — rate limit at ${rl.five_hour_pct}%`);
1425
+ this.logger.info({ target, scheduleId: id, rateLimitPct: rl.five_hour_pct }, "Schedule deferred due to rate limit");
1426
+ return;
1427
+ }
1428
+ const schedulerDefaults = this.fleetConfig?.defaults.scheduler;
1429
+ const retryCount = schedulerDefaults?.retry_count ?? 3;
1430
+ const retryInterval = schedulerDefaults?.retry_interval_ms ?? 30_000;
1431
+ const deliver = () => {
1432
+ const ipc = this.instanceIpcClients.get(target);
1433
+ if (!ipc?.connected)
1434
+ return false;
1435
+ ipc.send({
1436
+ type: "fleet_schedule_trigger",
1437
+ payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
1438
+ meta: { chat_id: reply_chat_id, thread_id: reply_thread_id, user: "scheduler" },
1439
+ });
1440
+ return true;
1441
+ };
1442
+ if (deliver()) {
1443
+ this.scheduler.recordRun(id, "delivered");
1444
+ if (source !== target)
1445
+ this.notifySourceTopic(schedule);
1446
+ return;
1447
+ }
1448
+ for (let i = 0; i < retryCount; i++) {
1449
+ await new Promise((r) => setTimeout(r, retryInterval));
1450
+ if (deliver()) {
1451
+ this.scheduler.recordRun(id, "delivered");
1452
+ if (source !== target)
1453
+ this.notifySourceTopic(schedule);
1454
+ return;
1455
+ }
1456
+ }
1457
+ this.scheduler.recordRun(id, "instance_offline", `retry ${retryCount}x failed`);
1458
+ this.notifyScheduleFailure(schedule);
1459
+ }
1460
+ notifySourceTopic(schedule) {
1461
+ const adapter = this.getAdapterForInstance(schedule.target) ?? this.adapter;
1462
+ if (!adapter)
1463
+ return;
1464
+ const text = `⏰ Schedule "${schedule.label ?? schedule.id}" triggered, target: ${schedule.target}`;
1465
+ adapter.sendText(schedule.reply_chat_id, text, {
1466
+ threadId: schedule.reply_thread_id ?? undefined,
1467
+ }).catch((err) => this.logger.error({ err }, "Failed to send cross-instance notification"));
1468
+ }
1469
+ notifyScheduleFailure(schedule) {
1470
+ const adapter = this.getAdapterForInstance(schedule.target) ?? this.adapter;
1471
+ if (!adapter)
1472
+ return;
1473
+ const text = `⏰ Schedule "${schedule.label ?? schedule.id}" trigger failed: instance ${schedule.target} is offline.`;
1474
+ adapter.sendText(schedule.reply_chat_id, text, {
1475
+ threadId: schedule.reply_thread_id ?? undefined,
1476
+ }).catch((err) => this.logger.error({ err }, "Failed to send schedule failure notification"));
1477
+ }
1478
+ handleScheduleCrud(instanceName, msg) {
1479
+ const fleetRequestId = msg.fleetRequestId;
1480
+ const payload = (msg.payload ?? {});
1481
+ const meta = (msg.meta ?? {});
1482
+ const ipc = this.instanceIpcClients.get(instanceName);
1483
+ if (!ipc)
1484
+ return;
1485
+ try {
1486
+ let result;
1487
+ switch (msg.type) {
1488
+ case "fleet_schedule_create": {
1489
+ const params = {
1490
+ cron: payload.cron,
1491
+ message: payload.message,
1492
+ source: instanceName,
1493
+ target: payload.target || instanceName,
1494
+ reply_chat_id: meta.chat_id,
1495
+ reply_thread_id: meta.thread_id || null,
1496
+ label: payload.label,
1497
+ timezone: payload.timezone,
1498
+ };
1499
+ result = this.scheduler.create(params);
1500
+ break;
1501
+ }
1502
+ case "fleet_schedule_list":
1503
+ result = this.scheduler.list(payload.target);
1504
+ break;
1505
+ case "fleet_schedule_update":
1506
+ result = this.scheduler.update(payload.id, payload);
1507
+ break;
1508
+ case "fleet_schedule_delete":
1509
+ this.scheduler.delete(payload.id);
1510
+ result = "ok";
1511
+ break;
1512
+ }
1513
+ ipc.send({ type: "fleet_schedule_response", fleetRequestId, result });
1514
+ }
1515
+ catch (err) {
1516
+ ipc.send({ type: "fleet_schedule_response", fleetRequestId, error: err.message });
1517
+ }
1518
+ }
1519
+ handleDecisionCrud(instanceName, msg) {
1520
+ const fleetRequestId = msg.fleetRequestId;
1521
+ const payload = (msg.payload ?? {});
1522
+ const meta = (msg.meta ?? {});
1523
+ const ipc = this.instanceIpcClients.get(instanceName);
1524
+ if (!ipc || !this.scheduler)
1525
+ return;
1526
+ const db = this.scheduler.db;
1527
+ const projectRoot = meta.working_directory || this.fleetConfig?.instances[instanceName]?.working_directory || "";
1528
+ try {
1529
+ let result;
1530
+ switch (msg.type) {
1531
+ case "fleet_decision_create": {
1532
+ // Prune expired decisions on create
1533
+ db.pruneExpiredDecisions();
1534
+ result = db.createDecision({
1535
+ project_root: projectRoot,
1536
+ scope: payload.scope,
1537
+ title: payload.title,
1538
+ content: payload.content,
1539
+ tags: payload.tags,
1540
+ ttl_days: payload.ttl_days,
1541
+ created_by: instanceName,
1542
+ supersedes: payload.supersedes,
1543
+ });
1544
+ break;
1545
+ }
1546
+ case "fleet_decision_list":
1547
+ db.pruneExpiredDecisions();
1548
+ result = db.listDecisions(projectRoot, {
1549
+ includeArchived: payload.include_archived,
1550
+ tags: payload.tags,
1551
+ });
1552
+ break;
1553
+ case "fleet_decision_update": {
1554
+ const id = payload.id;
1555
+ if (payload.archive) {
1556
+ db.archiveDecision(id);
1557
+ result = { archived: true, id };
1558
+ }
1559
+ else {
1560
+ result = db.updateDecision(id, {
1561
+ content: payload.content,
1562
+ tags: payload.tags,
1563
+ ttl_days: payload.ttl_days,
1564
+ });
1565
+ }
1566
+ break;
1567
+ }
1568
+ }
1569
+ ipc.send({ type: "fleet_decision_response", fleetRequestId, result });
1570
+ }
1571
+ catch (err) {
1572
+ ipc.send({ type: "fleet_decision_response", fleetRequestId, error: err.message });
1573
+ }
1574
+ }
1575
+ /** Resolve display name for an instance, fallback to instance name. */
1576
+ resolveDisplayName(instanceName) {
1577
+ return this.fleetConfig?.instances[instanceName]?.display_name ?? instanceName;
1578
+ }
1579
+ handleSetDisplayName(instanceName, msg) {
1580
+ const fleetRequestId = msg.fleetRequestId;
1581
+ const payload = (msg.payload ?? {});
1582
+ const ipc = this.instanceIpcClients.get(instanceName);
1583
+ if (!ipc || !this.fleetConfig)
1584
+ return;
1585
+ const displayName = payload.name;
1586
+ if (!displayName || displayName.length > 30) {
1587
+ ipc.send({ type: "fleet_display_name_response", fleetRequestId, error: "Name must be 1-30 characters" });
1588
+ return;
1589
+ }
1590
+ this.fleetConfig.instances[instanceName].display_name = displayName;
1591
+ this.saveFleetConfig();
1592
+ this.logger.info({ instanceName, displayName }, "Display name set");
1593
+ ipc.send({ type: "fleet_display_name_response", fleetRequestId, result: { display_name: displayName } });
1594
+ }
1595
+ handleSetDescription(instanceName, msg) {
1596
+ const fleetRequestId = msg.fleetRequestId;
1597
+ const payload = (msg.payload ?? {});
1598
+ const ipc = this.instanceIpcClients.get(instanceName);
1599
+ if (!ipc || !this.fleetConfig)
1600
+ return;
1601
+ const description = payload.description;
1602
+ if (!description) {
1603
+ ipc.send({ type: "fleet_description_response", fleetRequestId, error: "Description cannot be empty" });
1604
+ return;
1605
+ }
1606
+ this.fleetConfig.instances[instanceName].description = description;
1607
+ this.saveFleetConfig();
1608
+ this.logger.info({ instanceName, description: description.slice(0, 80) }, "Description set");
1609
+ ipc.send({ type: "fleet_description_response", fleetRequestId, result: { description } });
1610
+ }
1611
+ // ── Agent CLI HTTP handlers ─────────────────────────────────────────
1612
+ async handleScheduleCrudHttp(instance, op, args) {
1613
+ if (!this.scheduler)
1614
+ return { error: "Scheduler not available" };
1615
+ switch (op) {
1616
+ case "create":
1617
+ return this.scheduler.create({
1618
+ cron: args.cron, message: args.message,
1619
+ source: instance, target: args.target || instance,
1620
+ reply_chat_id: "", reply_thread_id: null,
1621
+ label: args.label,
1622
+ timezone: args.timezone,
1623
+ });
1624
+ case "list": return this.scheduler.list(args.target);
1625
+ case "update": return this.scheduler.update(args.id, args);
1626
+ case "delete":
1627
+ this.scheduler.delete(args.id);
1628
+ return "ok";
1629
+ default: return { error: `Unknown schedule op: ${op}` };
1630
+ }
1631
+ }
1632
+ async handleDecisionCrudHttp(instance, op, args) {
1633
+ if (!this.scheduler)
1634
+ return { error: "Scheduler not available" };
1635
+ const db = this.scheduler.db;
1636
+ const projectRoot = this.fleetConfig?.instances[instance]?.working_directory ?? "";
1637
+ const asStr = (v) => typeof v === "string" ? v : undefined;
1638
+ const asNum = (v) => typeof v === "number" ? v : undefined;
1639
+ const asStrArr = (v) => Array.isArray(v) && v.every(x => typeof x === "string") ? v : undefined;
1640
+ switch (op) {
1641
+ case "post": {
1642
+ const title = asStr(args.title);
1643
+ const content = asStr(args.content);
1644
+ if (!title || !content)
1645
+ return { error: "title and content are required" };
1646
+ const scope = args.scope === "fleet" ? "fleet" : "project";
1647
+ return db.createDecision({
1648
+ project_root: projectRoot,
1649
+ scope,
1650
+ title,
1651
+ content,
1652
+ tags: asStrArr(args.tags),
1653
+ ttl_days: asNum(args.ttl_days),
1654
+ supersedes: asStr(args.supersedes),
1655
+ created_by: instance,
1656
+ });
1657
+ }
1658
+ case "list": return db.listDecisions(projectRoot, {
1659
+ includeArchived: args.includeArchived === true,
1660
+ tags: asStrArr(args.tags),
1661
+ });
1662
+ case "update": {
1663
+ const id = asStr(args.id);
1664
+ if (!id)
1665
+ return { error: "id is required" };
1666
+ return db.updateDecision(id, {
1667
+ content: asStr(args.content),
1668
+ tags: asStrArr(args.tags),
1669
+ ttl_days: asNum(args.ttl_days),
1670
+ });
1671
+ }
1672
+ default: return { error: `Unknown decision op: ${op}` };
1673
+ }
1674
+ }
1675
+ async handleTaskCrudHttp(instance, args) {
1676
+ if (!this.scheduler)
1677
+ return { error: "Scheduler not available" };
1678
+ const db = this.scheduler.db;
1679
+ const action = args.action;
1680
+ const asStr = (v) => typeof v === "string" ? v : undefined;
1681
+ const asStrArr = (v) => Array.isArray(v) && v.every(x => typeof x === "string") ? v : undefined;
1682
+ const asPriority = (v) => {
1683
+ return (v === "low" || v === "normal" || v === "high" || v === "urgent") ? v : undefined;
1684
+ };
1685
+ const asStatus = (v) => {
1686
+ return (v === "open" || v === "claimed" || v === "done" || v === "blocked" || v === "cancelled") ? v : undefined;
1687
+ };
1688
+ switch (action) {
1689
+ case "create": {
1690
+ const title = asStr(args.title);
1691
+ if (!title)
1692
+ return { error: "title is required" };
1693
+ return db.createTask({
1694
+ title,
1695
+ description: asStr(args.description),
1696
+ priority: asPriority(args.priority),
1697
+ assignee: asStr(args.assignee),
1698
+ depends_on: asStrArr(args.depends_on),
1699
+ created_by: instance,
1700
+ });
1701
+ }
1702
+ case "list": return db.listTasks({ assignee: asStr(args.filter_assignee), status: asStr(args.filter_status) });
1703
+ case "claim": {
1704
+ const id = asStr(args.id);
1705
+ if (!id)
1706
+ return { error: "id is required" };
1707
+ return db.claimTask(id, instance);
1708
+ }
1709
+ case "done": {
1710
+ const id = asStr(args.id);
1711
+ if (!id)
1712
+ return { error: "id is required" };
1713
+ return db.completeTask(id, asStr(args.result));
1714
+ }
1715
+ case "update": {
1716
+ const id = asStr(args.id);
1717
+ if (!id)
1718
+ return { error: "id is required" };
1719
+ return db.updateTask(id, {
1720
+ status: asStatus(args.status),
1721
+ assignee: asStr(args.assignee),
1722
+ result: asStr(args.result),
1723
+ priority: asPriority(args.priority),
1724
+ });
1725
+ }
1726
+ default: return { error: `Unknown task action: ${action}` };
1727
+ }
1728
+ }
1729
+ async handleSetDisplayNameHttp(instance, name) {
1730
+ if (!this.fleetConfig)
1731
+ return { error: "Fleet config not available" };
1732
+ if (!name || name.length > 30)
1733
+ return { error: "Name must be 1-30 characters" };
1734
+ this.fleetConfig.instances[instance].display_name = name;
1735
+ this.saveFleetConfig();
1736
+ return { display_name: name };
1737
+ }
1738
+ async handleSetDescriptionHttp(instance, description) {
1739
+ if (!this.fleetConfig)
1740
+ return { error: "Fleet config not available" };
1741
+ if (!description)
1742
+ return { error: "Description cannot be empty" };
1743
+ this.fleetConfig.instances[instance].description = description;
1744
+ this.saveFleetConfig();
1745
+ return { description };
1746
+ }
1747
+ summarizeToolCall(tool, args) {
1748
+ switch (tool) {
1749
+ case "send_to_instance": return `send_to_instance(${args.instance_name})`;
1750
+ case "broadcast": return `broadcast(${args.targets?.join(", ") ?? "all"})`;
1751
+ case "request_information": return `request_information(${args.target_instance}, "${(args.question ?? "").slice(0, 60)}")`;
1752
+ case "delegate_task": return `delegate_task(${args.target_instance}, "${(args.task ?? "").slice(0, 60)}")`;
1753
+ case "report_result": return `report_result(${args.target_instance})`;
1754
+ case "task": return `task(${args.action}${args.title ? `, "${args.title.slice(0, 40)}"` : args.id ? `, ${args.id.slice(0, 8)}` : ""})`;
1755
+ case "post_decision": return `post_decision("${(args.title ?? "").slice(0, 40)}")`;
1756
+ case "list_decisions": return "list_decisions()";
1757
+ case "list_instances": return "list_instances()";
1758
+ case "describe_instance": return `describe_instance(${args.name})`;
1759
+ case "start_instance": return `start_instance(${args.name})`;
1760
+ case "create_instance": return `create_instance(${args.directory})`;
1761
+ case "delete_instance": return `delete_instance(${args.name})`;
1762
+ case "replace_instance": return `replace_instance(${args.name})`;
1763
+ default: return `${tool}()`;
1764
+ }
1765
+ }
1766
+ handleTaskCrud(instanceName, msg) {
1767
+ const fleetRequestId = msg.fleetRequestId;
1768
+ const payload = (msg.payload ?? {});
1769
+ const meta = (msg.meta ?? {});
1770
+ const ipc = this.instanceIpcClients.get(instanceName);
1771
+ if (!ipc || !this.scheduler)
1772
+ return;
1773
+ const db = this.scheduler.db;
1774
+ const action = payload.action;
1775
+ try {
1776
+ let result;
1777
+ switch (action) {
1778
+ case "create":
1779
+ result = db.createTask({
1780
+ title: payload.title,
1781
+ description: payload.description,
1782
+ priority: payload.priority,
1783
+ assignee: payload.assignee,
1784
+ depends_on: payload.depends_on,
1785
+ created_by: meta.instance_name || instanceName,
1786
+ });
1787
+ break;
1788
+ case "list":
1789
+ result = db.listTasks({
1790
+ assignee: payload.filter_assignee,
1791
+ status: payload.filter_status,
1792
+ });
1793
+ break;
1794
+ case "claim":
1795
+ result = db.claimTask(payload.id, meta.instance_name || instanceName);
1796
+ break;
1797
+ case "done":
1798
+ result = db.completeTask(payload.id, payload.result);
1799
+ break;
1800
+ case "update":
1801
+ result = db.updateTask(payload.id, {
1802
+ status: payload.status,
1803
+ assignee: payload.assignee,
1804
+ result: payload.result,
1805
+ priority: payload.priority,
1806
+ });
1807
+ break;
1808
+ default:
1809
+ throw new Error(`Unknown task action: ${action}`);
1810
+ }
1811
+ ipc.send({ type: "fleet_task_response", fleetRequestId, result });
1812
+ // Activity log for task lifecycle events
1813
+ if (action === "create") {
1814
+ const t = result;
1815
+ this.eventLog?.logActivity("task_update", instanceName, `created task: ${t.title}`, t.assignee ?? undefined);
1816
+ }
1817
+ else if (action === "claim") {
1818
+ const t = result;
1819
+ this.eventLog?.logActivity("task_update", instanceName, `claimed: ${t.title}`);
1820
+ }
1821
+ else if (action === "done") {
1822
+ const t = result;
1823
+ this.eventLog?.logActivity("task_update", instanceName, `completed: ${t.title}`, undefined, t.result ?? undefined);
1824
+ }
1825
+ }
1826
+ catch (err) {
1827
+ ipc.send({ type: "fleet_task_response", fleetRequestId, error: err.message });
1828
+ }
1829
+ }
1830
+ // ===================== Topic management =====================
1831
+ /** Create a forum topic via the adapter. Returns the message_thread_id. */
1832
+ async createForumTopic(topicName, adapterId) {
1833
+ const adapter = (adapterId ? this.worlds.get(adapterId)?.adapter : undefined) ?? this.adapter;
1834
+ if (!adapter?.createTopic) {
1835
+ throw new Error("Adapter does not support topic creation");
1836
+ }
1837
+ return adapter.createTopic(topicName);
1838
+ }
1839
+ async deleteForumTopic(topicId) {
1840
+ try {
1841
+ if (!this.adapter?.deleteTopic)
1842
+ return;
1843
+ await this.adapter.deleteTopic(topicId);
1844
+ }
1845
+ catch (err) {
1846
+ this.logger.warn({ err, topicId }, "Failed to delete forum topic during rollback");
1847
+ }
1848
+ }
1849
+ topicCleanupTimer = null;
1850
+ sessionPruneTimer = null;
1851
+ classicReloadTimer = null;
1852
+ botUserId;
1853
+ /** Periodically check if bound topics still exist */
1854
+ startTopicCleanupPoller() {
1855
+ this.topicCleanupTimer = setInterval(async () => {
1856
+ if (!this.fleetConfig?.channel?.group_id || !this.adapter?.topicExists)
1857
+ return;
1858
+ for (const [threadId, target] of this.routing.entries()) {
1859
+ try {
1860
+ if (!isProbeableRouteTarget(target)) {
1861
+ continue;
1862
+ }
1863
+ const exists = await this.adapter.topicExists(threadId);
1864
+ if (!exists) {
1865
+ await this.topicCommands.handleTopicDeleted(threadId);
1866
+ }
1867
+ }
1868
+ catch (err) {
1869
+ this.logger.debug({ err, threadId }, "Topic existence check failed");
1870
+ }
1871
+ }
1872
+ }, 5 * 60_000);
1873
+ }
1874
+ /** Save fleet config back to fleet.yaml */
1875
+ saveFleetConfig() {
1876
+ if (!this.fleetConfig || !this.configPath)
1877
+ return;
1878
+ const toSave = {};
1879
+ if (this.fleetConfig.project_roots)
1880
+ toSave.project_roots = this.fleetConfig.project_roots;
1881
+ if (this.fleetConfig.channels && this.fleetConfig.channels.length > 0) {
1882
+ toSave.channels = this.fleetConfig.channels;
1883
+ }
1884
+ else if (this.fleetConfig.channel) {
1885
+ toSave.channel = this.fleetConfig.channel;
1886
+ }
1887
+ if (this.fleetConfig.health_port)
1888
+ toSave.health_port = this.fleetConfig.health_port;
1889
+ if (Object.keys(this.fleetConfig.defaults).length > 0)
1890
+ toSave.defaults = this.fleetConfig.defaults;
1891
+ if (this.fleetConfig.teams && Object.keys(this.fleetConfig.teams).length > 0) {
1892
+ toSave.teams = this.fleetConfig.teams;
1893
+ }
1894
+ if (this.fleetConfig.templates && Object.keys(this.fleetConfig.templates).length > 0) {
1895
+ toSave.templates = this.fleetConfig.templates;
1896
+ }
1897
+ if (this.fleetConfig.profiles && Object.keys(this.fleetConfig.profiles).length > 0) {
1898
+ toSave.profiles = this.fleetConfig.profiles;
1899
+ }
1900
+ toSave.instances = {};
1901
+ for (const [name, inst] of Object.entries(this.fleetConfig.instances)) {
1902
+ const serialized = {
1903
+ working_directory: inst.working_directory,
1904
+ topic_id: inst.topic_id,
1905
+ };
1906
+ // Preserve all optional user-configured fields so saveFleetConfig() never silently drops them
1907
+ if (inst.general_topic)
1908
+ serialized.general_topic = true;
1909
+ if (inst.description)
1910
+ serialized.description = inst.description;
1911
+ if (inst.tags?.length)
1912
+ serialized.tags = inst.tags;
1913
+ if (inst.model)
1914
+ serialized.model = inst.model;
1915
+ if (inst.model_failover?.length)
1916
+ serialized.model_failover = inst.model_failover;
1917
+ if (inst.worktree_source)
1918
+ serialized.worktree_source = inst.worktree_source;
1919
+ if (inst.backend)
1920
+ serialized.backend = inst.backend;
1921
+ if (inst.systemPrompt)
1922
+ serialized.systemPrompt = inst.systemPrompt;
1923
+ if (inst.skipPermissions)
1924
+ serialized.skipPermissions = inst.skipPermissions;
1925
+ if (inst.lightweight)
1926
+ serialized.lightweight = inst.lightweight;
1927
+ if (inst.cost_guard)
1928
+ serialized.cost_guard = inst.cost_guard;
1929
+ if (inst.workflow !== undefined)
1930
+ serialized.workflow = inst.workflow;
1931
+ if (inst.agent_mode)
1932
+ serialized.agent_mode = inst.agent_mode;
1933
+ toSave.instances[name] = serialized;
1934
+ }
1935
+ writeFileSync(this.configPath, yaml.dump(toSave, { lineWidth: 120 }));
1936
+ this.logger.info({ path: this.configPath }, "Saved fleet config");
1937
+ }
1938
+ async removeInstance(name) {
1939
+ // Clean up schedules (scheduler is fleet-level, not lifecycle-level)
1940
+ const config = this.fleetConfig?.instances[name];
1941
+ if (this.scheduler && config?.topic_id) {
1942
+ const count = this.scheduler.deleteByInstanceOrThread(name, String(config.topic_id));
1943
+ if (count > 0) {
1944
+ this.logger.info({ name, count }, "Cleaned up schedules for deleted instance");
1945
+ }
1946
+ }
1947
+ // Clean up team memberships
1948
+ if (this.fleetConfig?.teams) {
1949
+ for (const [teamName, team] of Object.entries(this.fleetConfig.teams)) {
1950
+ const idx = team.members.indexOf(name);
1951
+ if (idx !== -1) {
1952
+ team.members.splice(idx, 1);
1953
+ this.logger.info({ team: teamName, instance: name }, "Removed deleted instance from team");
1954
+ }
1955
+ if (team.members.length === 0) {
1956
+ delete this.fleetConfig.teams[teamName];
1957
+ this.logger.info({ team: teamName }, "Deleted empty team");
1958
+ }
1959
+ }
1960
+ }
1961
+ await this.lifecycle.remove(name);
1962
+ // Clean up per-instance tracking maps so they don't grow unbounded
1963
+ // as instances are created and deleted over the lifetime of the fleet.
1964
+ this.lastActivity.delete(name);
1965
+ this.lastInboundUser.delete(name);
1966
+ this.rateLimitWarnedAt.delete(name);
1967
+ // Clean up statusline watcher + instance directory
1968
+ this.statuslineWatcher.unwatch(name);
1969
+ try {
1970
+ rmSync(this.getInstanceDir(name), { recursive: true, force: true });
1971
+ }
1972
+ catch (err) {
1973
+ this.logger.debug({ err, name }, "Instance dir cleanup failed");
1974
+ }
1975
+ }
1976
+ startStatuslineWatcher(name) {
1977
+ this.statuslineWatcher.watch(name);
1978
+ }
1979
+ reactMessageStatus(chatId, messageId, emoji) {
1980
+ // Find the adapter that owns this chatId (check all adapters, not just primary)
1981
+ for (const [, w] of this.worlds) {
1982
+ if (w.type === "discord") {
1983
+ w.react(chatId, messageId, emoji)
1984
+ .catch(e => this.logger.debug({ err: e.message }, "Message status react failed"));
1985
+ return;
1986
+ }
1987
+ }
1988
+ }
1989
+ // ── Model failover ──────────────────────────────────────────────────────
1990
+ static FAILOVER_TRIGGER_PCT = 90;
1991
+ static FAILOVER_RECOVER_PCT = 50;
1992
+ checkModelFailover(name, fiveHourPct) {
1993
+ const config = this.fleetConfig?.instances[name];
1994
+ if (!config?.model_failover?.length)
1995
+ return;
1996
+ const daemon = this.daemons.get(name);
1997
+ if (!daemon)
1998
+ return;
1999
+ const failoverList = config.model_failover;
2000
+ const primaryModel = failoverList[0];
2001
+ const currentFailover = this.failoverActive.get(name);
2002
+ if (fiveHourPct >= FleetManager.FAILOVER_TRIGGER_PCT && !currentFailover) {
2003
+ // Trigger failover: pick next model in list
2004
+ const fallbackModel = failoverList.length > 1 ? failoverList[1] : undefined;
2005
+ if (!fallbackModel)
2006
+ return;
2007
+ this.failoverActive.set(name, fallbackModel);
2008
+ daemon.setModelOverride(fallbackModel);
2009
+ this.logger.info({ instance: name, from: primaryModel, to: fallbackModel, ratePct: fiveHourPct }, "Model failover triggered");
2010
+ this.eventLog?.insert(name, "model_failover", {
2011
+ from: primaryModel, to: fallbackModel, five_hour_pct: fiveHourPct,
2012
+ });
2013
+ this.webhookEmitter?.emit("model_failover", name, { from: primaryModel, to: fallbackModel, five_hour_pct: fiveHourPct });
2014
+ this.notifyInstanceTopic(name, `⚡ Rate limit ${fiveHourPct}% — next rotation will use ${fallbackModel} (was ${primaryModel})`);
2015
+ }
2016
+ else if (fiveHourPct < FleetManager.FAILOVER_RECOVER_PCT && currentFailover) {
2017
+ // Recover: switch back to primary
2018
+ this.failoverActive.delete(name);
2019
+ daemon.setModelOverride(undefined);
2020
+ this.logger.info({ instance: name, restored: primaryModel, ratePct: fiveHourPct }, "Model failover recovered");
2021
+ this.eventLog?.insert(name, "model_recovered", {
2022
+ restored: primaryModel, five_hour_pct: fiveHourPct,
2023
+ });
2024
+ this.webhookEmitter?.emit("model_recovered", name, { restored: primaryModel, five_hour_pct: fiveHourPct });
2025
+ this.notifyInstanceTopic(name, `✅ Rate limit recovered (${fiveHourPct}%) — next rotation will use ${primaryModel}`);
2026
+ }
2027
+ }
2028
+ notifyInstanceTopic(instanceName, text) {
2029
+ const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
2030
+ if (!adapter)
2031
+ return;
2032
+ const channelCfg = this.getChannelConfig(this.instanceWorldBinding.get(instanceName));
2033
+ const groupId = channelCfg?.group_id;
2034
+ if (!groupId)
2035
+ return;
2036
+ const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
2037
+ adapter.sendText(String(groupId), text, {
2038
+ threadId: threadId != null ? String(threadId) : undefined,
2039
+ }).catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send instance topic notification"));
2040
+ }
2041
+ queueMirrorMessage(text) {
2042
+ const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
2043
+ if (mirrorTopicId == null || !this.adapter)
2044
+ return;
2045
+ const ts = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" });
2046
+ this.mirrorBuffer.push(`[${ts}] ${text}`);
2047
+ if (!this.mirrorTimer) {
2048
+ this.mirrorTimer = setTimeout(() => {
2049
+ const batch = this.mirrorBuffer.join("\n");
2050
+ this.mirrorBuffer = [];
2051
+ this.mirrorTimer = null;
2052
+ const groupId = this.fleetConfig?.channel?.group_id;
2053
+ if (groupId && this.adapter) {
2054
+ this.adapter.sendText(String(groupId), batch, {
2055
+ threadId: String(mirrorTopicId),
2056
+ }).catch(e => this.logger.debug({ err: e }, "Mirror topic send failed"));
2057
+ }
2058
+ }, 3000);
2059
+ }
2060
+ }
2061
+ /** Push an SSE event to all connected Web UI clients. */
2062
+ emitSseEvent(event, data) {
2063
+ broadcastSseEvent(this.sseClients, event, data, (err) => this.logger.debug({ err }, "SSE client write failed; evicting"));
2064
+ }
2065
+ listClaimedTasks(assignee) {
2066
+ try {
2067
+ return this.scheduler?.db.listTasks({ assignee, status: "claimed" }) ?? [];
2068
+ }
2069
+ catch {
2070
+ return [];
2071
+ }
2072
+ }
2073
+ async sendHangNotification(instanceName) {
2074
+ const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
2075
+ if (!adapter)
2076
+ return;
2077
+ const channelCfg = this.getChannelConfig(this.instanceWorldBinding.get(instanceName));
2078
+ const groupId = channelCfg?.group_id;
2079
+ if (!groupId)
2080
+ return;
2081
+ const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
2082
+ this.setTopicIcon(instanceName, "red");
2083
+ await adapter.notifyAlert(String(groupId), {
2084
+ type: "hang",
2085
+ instanceName,
2086
+ message: `⚠️ ${instanceName} appears hung (no activity for 15+ minutes)`,
2087
+ choices: [
2088
+ { id: `hang:restart:${instanceName}`, label: "🔄 Force restart" },
2089
+ { id: `hang:wait:${instanceName}`, label: "⏳ Keep waiting" },
2090
+ ],
2091
+ }, {
2092
+ threadId: threadId != null ? String(threadId) : undefined,
2093
+ }).catch(e => this.logger.warn({ err: e }, "Failed to send hang notification"));
2094
+ }
2095
+ // ── Topic icon + auto-archive ─────────────────────────────────────────────
2096
+ static INSTRUCTIONS_FILENAME = {
2097
+ "claude-code": "CLAUDE.md",
2098
+ "codex": "AGENTS.md",
2099
+ "gemini-cli": "GEMINI.md",
2100
+ "opencode": "AGENTS.md",
2101
+ "kiro-cli": ".kiro/steering/project.md",
2102
+ "mock": "CLAUDE.md",
2103
+ };
2104
+ static GENERAL_INSTRUCTIONS = `# Fleet Coordinator
2105
+
2106
+ You are the fleet coordinator — the central entry point for this AgEnD fleet.
2107
+ You route tasks, manage instances, enforce policies, and synthesize results.
2108
+ Do NOT modify project files directly — delegate file changes to the project's instance.
2109
+ You CAN write code snippets, explain code, and answer technical questions directly.
2110
+
2111
+ -----
2112
+
2113
+ ## Task Classification
2114
+
2115
+ Classify every incoming request before acting.
2116
+
2117
+ ### Handle Directly (ALL conditions must be true)
2118
+
2119
+ - No file system access needed
2120
+ - No external execution needed
2121
+ - Answerable from static knowledge
2122
+ - ≤ 2 reasoning steps
2123
+
2124
+ Examples: Q&A, translation, fleet status queries, explaining a concept, writing code snippets.
2125
+
2126
+ ### Delegate to 1 Instance
2127
+
2128
+ - Task scoped to a single project or repo
2129
+ - Requires file access, code changes, or execution
2130
+
2131
+ ### Coordinate Multiple Instances
2132
+
2133
+ - Task spans multiple repos or domains
2134
+ - Requires outputs from one instance to feed into another
2135
+ - Benefits from parallel execution (max 3 instances per task)
2136
+
2137
+ -----
2138
+
2139
+ ## Instance Discovery (in this order)
2140
+ 1. list_teams() → reuse existing teams first
2141
+ 2. list_instances() → find by working_directory, description, or tags
2142
+ 3. describe_instance() → confirm capabilities before delegating
2143
+ 4. create_instance() → only if no suitable instance exists
2144
+
2145
+ Rules: prefer reuse over creation. Do NOT create duplicates of running instances.
2146
+
2147
+ -----
2148
+
2149
+ ## Delegation Protocol
2150
+
2151
+ Every delegation via send_to_instance() MUST include:
2152
+
2153
+ 1. Task scope — what exactly to do, bounded clearly
2154
+ 2. Expected output — what to return and in what form
2155
+ 3. Policy reminder — "Follow Development Workflow policy" (for code tasks)
2156
+
2157
+ ### Loop Prevention
2158
+
2159
+ - Never re-delegate a task back to the instance that sent it to you
2160
+ - If a task has bounced 3 times, stop and solve locally or reduce scope
2161
+
2162
+ ### Execution Strategy
2163
+
2164
+ Parallel — use only when tasks are independent with no shared state
2165
+ Sequential — use when one task's output feeds into the next
2166
+
2167
+ -----
2168
+
2169
+ ## Result Handling
2170
+
2171
+ When an instance reports back, classify the outcome:
2172
+
2173
+ - Success → Summarize key results for user. Omit internal coordination noise.
2174
+ - Partial → State what succeeded, what remains, proposed next steps.
2175
+ - Failure → Retry up to 2 times. If still failing: try alternative instance, reduce scope, or return partial result clearly marked.
2176
+ - No response → Ping again after reasonable wait. If still silent: report to user with options.
2177
+
2178
+ ### Output to User
2179
+
2180
+ Every final response to the user should contain:
2181
+
2182
+ - Result — the actual answer or deliverable
2183
+ - Gaps — anything incomplete or unresolved (omit if none)
2184
+
2185
+ -----
2186
+
2187
+ ## Shared Decisions
2188
+
2189
+ 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.
2190
+
2191
+ When instances disagree, collect both viewpoints, make a decision, and record it via post_decision.
2192
+
2193
+ -----
2194
+
2195
+ ## Context Rotation Bootstrap
2196
+
2197
+ After your context rotates, run this sequence BEFORE processing any new messages:
2198
+ 1. list_instances() → rebuild fleet awareness
2199
+ 2. list_teams() → restore team structure
2200
+ 3. list_decisions() → reload policies and conventions
2201
+
2202
+ Only then handle incoming requests.
2203
+
2204
+ -----
2205
+
2206
+ ## Development Workflow Policy
2207
+
2208
+ All code changes across the fleet should follow this workflow.
2209
+ The coordinator enforces compliance but does not perform these steps directly.
2210
+ Remind instances of this policy when delegating code tasks.
2211
+
2212
+ ### Workflow Stages
2213
+ Design Proposed → Design Approved → Implementation → Submit for Review → Under Review → Approved → Merge
2214
+
2215
+ ### Policy Rules
2216
+
2217
+ 1. Design before code — developer sends design proposal to reviewer before implementation. Consensus required before proceeding.
2218
+ 2. Challenger pairing — every code task should have a developer + reviewer. Reviewer actively questions decisions and finds risks.
2219
+ 3. Verify by execution — backend/CLI changes must be tested by running them. Do not trust documentation alone.
2220
+ 4. Independent review — every merge requires code review from someone other than the author.
2221
+ 5. Root cause first — bug fixes require confirmed root cause before proposing a fix.
2222
+ 6. Merge conditions: tests pass, reviewer approved, branch and worktree cleaned up.
2223
+
2224
+ ### Specialist Instance Rules
2225
+
2226
+ - Execute within defined scope only
2227
+ - Return structured output: result, assumptions, uncertainties, verification status
2228
+ - Do NOT create new instances without coordinator approval
2229
+
2230
+ -----
2231
+
2232
+ ## Team Management
2233
+
2234
+ - Always check existing teams before creating new ones
2235
+ - Default to ephemeral teams (created for a specific task, dissolved after completion)
2236
+ - Clean up ephemeral teams and instances after task completion
2237
+
2238
+ -----
2239
+
2240
+ ## Instance Configuration Tips
2241
+
2242
+ When users create specialized instances, suggest these configurations:
2243
+
2244
+ - **Reviewer instances**: Add \`pre_task_command: "/chat load reviewer-base"\` to reset context before each review, preventing influence from previous conversations.
2245
+ - **Collab mode**: For multi-bot channels, use \`/collab\` to enable @mention-based triggering.
2246
+ - **Cost control**: Set per-instance \`cost_guard\` for expensive backends.
2247
+ `;
2248
+ /** Ensure the general instance has its project instructions file + knowledge */
2249
+ ensureGeneralInstructions(workDir, backendName) {
2250
+ const backend = backendName ?? "claude-code";
2251
+ const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
2252
+ const filePath = join(workDir, filename);
2253
+ mkdirSync(dirname(filePath), { recursive: true });
2254
+ if (!existsSync(filePath)) {
2255
+ writeFileSync(filePath, FleetManager.GENERAL_INSTRUCTIONS, "utf-8");
2256
+ this.logger.info({ filePath }, "Created general instance instructions file");
2257
+ }
2258
+ // Sync bundled knowledge files to general's steering directory
2259
+ this.syncGeneralKnowledge(workDir, backend);
2260
+ }
2261
+ /** Copy src/general-knowledge/*.md to the general instance's steering dir */
2262
+ syncGeneralKnowledge(workDir, backend) {
2263
+ const knowledgeDir = join(dirname(fileURLToPath(import.meta.url)), "general-knowledge");
2264
+ if (!existsSync(knowledgeDir))
2265
+ return;
2266
+ const steeringDir = backend === "kiro-cli"
2267
+ ? join(workDir, ".kiro", "steering")
2268
+ : workDir; // other backends: put in working dir root
2269
+ mkdirSync(steeringDir, { recursive: true });
2270
+ for (const file of readdirSync(knowledgeDir)) {
2271
+ if (!file.endsWith(".md"))
2272
+ continue;
2273
+ const src = join(knowledgeDir, file);
2274
+ const dest = join(steeringDir, file);
2275
+ // Only write if content actually changed — avoids triggering instructions hash diff
2276
+ const newContent = readFileSync(src, "utf-8");
2277
+ try {
2278
+ if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
2279
+ continue;
2280
+ }
2281
+ catch { }
2282
+ writeFileSync(dest, newContent);
2283
+ }
2284
+ this.logger.debug({ knowledgeDir, steeringDir }, "Synced general knowledge files");
2285
+ }
2286
+ /** Fetch forum topic icon stickers and pick emoji IDs for each state */
2287
+ async resolveTopicIcons() {
2288
+ if (!this.adapter?.getTopicIconStickers)
2289
+ return;
2290
+ try {
2291
+ const stickers = await this.adapter.getTopicIconStickers();
2292
+ if (stickers.length === 0)
2293
+ return;
2294
+ // getForumTopicIconStickers returns a fixed set of available icons.
2295
+ // Try to match by emoji character, fall back to positional.
2296
+ const find = (targets) => stickers.find((s) => targets.some((t) => s.emoji.includes(t)));
2297
+ const green = find(["🟢", "✅", "💚"]);
2298
+ const blue = find(["🔵", "💙", "📘"]);
2299
+ const red = find(["🔴", "❌", "💔"]);
2300
+ this.topicIcons = {
2301
+ green: green?.customEmojiId ?? stickers[0]?.customEmojiId,
2302
+ blue: blue?.customEmojiId ?? stickers[1]?.customEmojiId ?? stickers[0]?.customEmojiId,
2303
+ red: red?.customEmojiId ?? stickers[Math.min(5, stickers.length - 1)]?.customEmojiId,
2304
+ };
2305
+ this.logger.info({ icons: this.topicIcons }, "Resolved topic icon emoji IDs");
2306
+ }
2307
+ catch (err) {
2308
+ this.logger.debug({ err }, "Failed to resolve topic icons (non-fatal)");
2309
+ }
2310
+ }
2311
+ /** Set topic icon based on instance state */
2312
+ setTopicIcon(instanceName, state) {
2313
+ const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
2314
+ const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
2315
+ if (topicId == null || !adapter?.editForumTopic)
2316
+ return;
2317
+ const emojiId = state === "remove" ? "" : this.topicIcons[state];
2318
+ if (emojiId == null && state !== "remove")
2319
+ return;
2320
+ adapter.editForumTopic(topicId, { iconCustomEmojiId: emojiId })
2321
+ .catch((e) => this.logger.debug({ err: e, instanceName, state }, "Topic icon update failed"));
2322
+ }
2323
+ /** Track activity timestamp for idle detection */
2324
+ touchActivity(instanceName) {
2325
+ this.lastActivity.set(instanceName, Date.now());
2326
+ }
2327
+ /** Start periodic idle archive checker */
2328
+ // archiveIdleTopics / reopenArchivedTopic → delegated to TopicArchiver
2329
+ clearStatuslineWatchers() {
2330
+ this.statuslineWatcher.stopAll();
2331
+ this.failoverActive.clear();
2332
+ }
2333
+ // ── Classic Channel Methods ──────────────────────────────────────────
2334
+ /** Handle a message in a classic channel: log it, forward only /chat messages */
2335
+ async handleClassicChannelMessage(instanceName, msg) {
2336
+ const text = msg.text ?? "";
2337
+ const channelId = msg.threadId ?? msg.chatId;
2338
+ const isCollabMode = this.classicChannels?.isCollab(channelId) ?? false;
2339
+ // Collab mode: trigger on @mention of our bot, log all messages
2340
+ if (isCollabMode) {
2341
+ // Log every message (including other bots) to chat-logs
2342
+ ClassicChannelManager.logMessage(instanceName, msg.username, text, msg.timestamp, msg.replyToText);
2343
+ this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
2344
+ // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here
2345
+ const adapterBotUserId = this.worlds.get(msg.adapterId ?? "")?.botUserId ?? this.botUserId;
2346
+ const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
2347
+ const isMentioned = mentionTag && text.includes(mentionTag);
2348
+ if (!isMentioned) {
2349
+ // Save bare attachments (stickers, images) even without @mention
2350
+ if (msg.attachments?.length) {
2351
+ const saved = await this.saveClassicAttachment(instanceName, msg);
2352
+ if (saved) {
2353
+ const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
2354
+ const noMentionReactChatId = msg.threadId ?? msg.chatId;
2355
+ if (reactAdapter && noMentionReactChatId && msg.messageId) {
2356
+ const emoji = msg.source === "telegram"
2357
+ ? (saved.kind === "photo" ? "👌" : "👍")
2358
+ : (saved.kind === "photo" ? "📸" : "📎");
2359
+ reactAdapter.react(noMentionReactChatId, msg.messageId, emoji)
2360
+ .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
2361
+ }
2362
+ }
2363
+ }
2364
+ return;
2365
+ }
2366
+ // Strip the @mention from text
2367
+ const cleanText = text.replace(new RegExp(`<@${adapterBotUserId}>`, "g"), "").trim();
2368
+ if (!cleanText && !msg.attachments?.length)
2369
+ return;
2370
+ const classicAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
2371
+ const collabReactChatId = msg.threadId ?? msg.chatId;
2372
+ if (classicAdapter && collabReactChatId && msg.messageId) {
2373
+ classicAdapter.react(collabReactChatId, msg.messageId, "👀")
2374
+ .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
2375
+ }
2376
+ // Block /raw bypass
2377
+ if (cleanText.startsWith("/raw "))
2378
+ return;
2379
+ // Save and process attachments (same as /chat mode)
2380
+ const saved = await this.saveClassicAttachment(instanceName, msg);
2381
+ if (saved && classicAdapter && collabReactChatId && msg.messageId) {
2382
+ const emoji = msg.source === "telegram"
2383
+ ? (saved.kind === "photo" ? "👌" : "👍")
2384
+ : (saved.kind === "photo" ? "📸" : "📎");
2385
+ classicAdapter.react(collabReactChatId, msg.messageId, emoji)
2386
+ .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
2387
+ }
2388
+ // Strip saved attachment to avoid double download
2389
+ const savedKind = saved?.kind;
2390
+ const patchedAttachments = savedKind ? msg.attachments?.filter(a => a.kind !== savedKind) : msg.attachments;
2391
+ const patchedMsg = { ...msg, text: cleanText, attachments: patchedAttachments?.length ? patchedAttachments : undefined };
2392
+ const { text: processedText, extraMeta } = await processAttachments(patchedMsg, classicAdapter, this.logger, instanceName);
2393
+ let finalText = processedText || cleanText;
2394
+ if (saved) {
2395
+ if (saved.kind === "photo") {
2396
+ extraMeta.image_path = saved.paths[0];
2397
+ if (saved.paths.length > 1)
2398
+ extraMeta.image_paths = saved.paths.join(",");
2399
+ const tags = saved.paths.map(p => `[📷 Image: ${p}]`).join("\n");
2400
+ finalText = `${tags}\n${finalText}`;
2401
+ }
2402
+ else {
2403
+ extraMeta.attachment_path = saved.paths[0];
2404
+ if (saved.paths.length > 1)
2405
+ extraMeta.attachment_paths = saved.paths.join(",");
2406
+ const docAtts = msg.attachments?.filter(a => a.kind === "document") ?? [];
2407
+ const tags = saved.paths.map((p, i) => {
2408
+ const filename = docAtts[i]?.filename ?? "file";
2409
+ return `[📎 File: ${filename} → ${p}]`;
2410
+ }).join("\n");
2411
+ finalText = `${tags}\n${finalText}`;
2412
+ }
2413
+ }
2414
+ await this.forwardToClassicInstance(instanceName, finalText, msg, extraMeta);
2415
+ return;
2416
+ }
2417
+ // Normal mode: /chat trigger
2418
+ const isChat = text.startsWith("/chat ") || text === "/chat";
2419
+ this.logger.info({ instanceName, user: msg.username, textLen: text.length, hasChat: isChat }, "classic channel message received");
2420
+ // Save photos/documents to workspace inbox so agent can read them later
2421
+ const saved = await this.saveClassicAttachment(instanceName, msg);
2422
+ // Log every message to the daily chat log (include saved path)
2423
+ const attachmentTag = saved ? ` [${saved.kind === "photo" ? "📷" : "📎"} saved: ${saved.paths.join(", ")}]`
2424
+ : msg.attachments?.length ? ` [${msg.attachments.map(a => `📎 ${a.kind}${a.filename ? `: ${a.filename}` : ""}`).join(", ")}]`
2425
+ : "";
2426
+ ClassicChannelManager.logMessage(instanceName, msg.username, text + attachmentTag, msg.timestamp, msg.replyToText);
2427
+ // Bare attachment without /chat: save + log only, don't trigger agent
2428
+ if (!isChat) {
2429
+ const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
2430
+ const reactChatId = msg.threadId ?? msg.chatId;
2431
+ if (saved && reactAdapter && reactChatId && msg.messageId) {
2432
+ // Telegram only supports limited emoji for reactions; use 👌 for photo, 👍 for file
2433
+ const emoji = msg.source === "telegram"
2434
+ ? (saved.kind === "photo" ? "👌" : "👍")
2435
+ : (saved.kind === "photo" ? "📸" : "📎");
2436
+ reactAdapter.react(reactChatId, msg.messageId, emoji)
2437
+ .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
2438
+ }
2439
+ return;
2440
+ }
2441
+ // /chat message: forward to agent
2442
+ const chatText = text.replace(/^\/chat\s*/, "").trim();
2443
+ if (!chatText && !msg.attachments?.length)
2444
+ return;
2445
+ // Block /raw bypass — admin commands must go through slash command gate
2446
+ if (chatText.startsWith("/raw "))
2447
+ return;
2448
+ // Strip saved attachment from attachments to avoid double download
2449
+ const savedKind = saved?.kind;
2450
+ const patchedAttachments = savedKind ? msg.attachments?.filter(a => a.kind !== savedKind) : msg.attachments;
2451
+ const patchedMsg = { ...msg, text: chatText, attachments: patchedAttachments?.length ? patchedAttachments : undefined };
2452
+ const classicMsgAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
2453
+ const { text: processedText, extraMeta } = await processAttachments(patchedMsg, classicMsgAdapter, this.logger, instanceName);
2454
+ // Use workspace inbox path for saved attachment
2455
+ let finalText = processedText || chatText;
2456
+ if (saved) {
2457
+ if (saved.kind === "photo") {
2458
+ extraMeta.image_path = saved.paths[0];
2459
+ if (saved.paths.length > 1)
2460
+ extraMeta.image_paths = saved.paths.join(",");
2461
+ const tags = saved.paths.map(p => `[📷 Image: ${p}]`).join("\n");
2462
+ finalText = `${tags}\n${chatText}`;
2463
+ }
2464
+ else {
2465
+ extraMeta.attachment_path = saved.paths[0];
2466
+ if (saved.paths.length > 1)
2467
+ extraMeta.attachment_paths = saved.paths.join(",");
2468
+ const docAtts = msg.attachments?.filter(a => a.kind === "document") ?? [];
2469
+ const tags = saved.paths.map((p, i) => {
2470
+ const filename = docAtts[i]?.filename ?? "file";
2471
+ return `[📎 File: ${filename} → ${p}]`;
2472
+ }).join("\n");
2473
+ finalText = `${tags}\n${chatText}`;
2474
+ }
2475
+ }
2476
+ if (msg.chatId && msg.messageId) {
2477
+ const reactChatId = msg.threadId ?? msg.chatId;
2478
+ classicMsgAdapter.react(reactChatId, msg.messageId, "👀")
2479
+ .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
2480
+ if (saved) {
2481
+ const savedEmoji = msg.source === "telegram"
2482
+ ? (saved.kind === "photo" ? "👌" : "👍")
2483
+ : (saved.kind === "photo" ? "📸" : "📎");
2484
+ classicMsgAdapter.react(reactChatId, msg.messageId, savedEmoji)
2485
+ .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
2486
+ }
2487
+ }
2488
+ await this.forwardToClassicInstance(instanceName, finalText, msg, extraMeta);
2489
+ }
2490
+ /** Download photo or document attachment to classic instance workspace inbox. Returns { path, kind } or undefined. */
2491
+ async saveClassicAttachment(instanceName, msg) {
2492
+ const atts = msg.attachments?.filter(a => a.kind === "photo" || a.kind === "document" || a.kind === "sticker") ?? [];
2493
+ const dlAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
2494
+ if (atts.length === 0 || !dlAdapter)
2495
+ return undefined;
2496
+ const paths = [];
2497
+ let kind = "document";
2498
+ for (const att of atts) {
2499
+ try {
2500
+ const tmpPath = await dlAdapter.downloadAttachment(att.fileId);
2501
+ const inboxDir = join(getAgendHome(), "workspaces", instanceName, "inbox");
2502
+ mkdirSync(inboxDir, { recursive: true });
2503
+ const dest = join(inboxDir, basename(tmpPath));
2504
+ try {
2505
+ renameSync(tmpPath, dest);
2506
+ }
2507
+ catch {
2508
+ copyFileSync(tmpPath, dest);
2509
+ unlinkSync(tmpPath);
2510
+ }
2511
+ const savedKind = att.kind === "sticker" ? "photo" : att.kind;
2512
+ paths.push(dest);
2513
+ if (paths.length === 1)
2514
+ kind = savedKind;
2515
+ this.logger.info({ instanceName, path: dest, kind: savedKind }, "Classic attachment saved to workspace inbox");
2516
+ }
2517
+ catch (err) {
2518
+ this.logger.warn({ err: err.message, instanceName }, "Classic attachment save failed");
2519
+ }
2520
+ }
2521
+ if (paths.length === 0)
2522
+ return undefined;
2523
+ return { path: paths[0], paths, kind };
2524
+ }
2525
+ /** Forward a message to a classic channel instance with chat log context */
2526
+ async forwardToClassicInstance(instanceName, text, msg, extraMeta) {
2527
+ const logContext = this.getRecentChatLog(instanceName);
2528
+ const fullText = logContext
2529
+ ? `[Chat log for context]\n${logContext}\n\n[User message]\n${text}`
2530
+ : text;
2531
+ const ipc = this.instanceIpcClients.get(instanceName);
2532
+ if (!ipc) {
2533
+ this.logger.warn({ instanceName }, "Classic channel instance IPC not connected");
2534
+ return;
2535
+ }
2536
+ ipc.send({
2537
+ type: "fleet_inbound",
2538
+ content: fullText,
2539
+ targetSession: instanceName,
2540
+ meta: {
2541
+ chat_id: msg.chatId,
2542
+ message_id: msg.messageId,
2543
+ user: msg.username,
2544
+ user_id: msg.userId,
2545
+ ts: msg.timestamp.toISOString(),
2546
+ thread_id: msg.threadId ?? "",
2547
+ source: msg.source,
2548
+ ...extraMeta,
2549
+ ...(msg.replyToText ? { reply_to_text: msg.replyToText } : {}),
2550
+ },
2551
+ });
2552
+ this.lastInboundUser.set(instanceName, msg.username);
2553
+ this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
2554
+ }
2555
+ /** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
2556
+ pasteRawToClassicInstance(instanceName, text) {
2557
+ const ipc = this.instanceIpcClients.get(instanceName);
2558
+ if (!ipc) {
2559
+ this.logger.warn({ instanceName }, "Cannot paste raw: IPC not connected");
2560
+ return;
2561
+ }
2562
+ ipc.send({ type: "raw_paste", content: text });
2563
+ this.logger.info({ instanceName, text: text.slice(0, 100) }, "Raw paste sent to classic instance");
2564
+ }
2565
+ /** Read recent chat log (last ~50 lines) for agent context */
2566
+ getRecentChatLog(instanceName) {
2567
+ const logDir = ClassicChannelManager.chatLogDir(instanceName);
2568
+ const today = new Date().toISOString().slice(0, 10);
2569
+ const logFile = join(logDir, `${today}.log`);
2570
+ try {
2571
+ if (!existsSync(logFile))
2572
+ return undefined;
2573
+ const lines = readFileSync(logFile, "utf-8").trim().split("\n");
2574
+ return lines.slice(-10).join("\n");
2575
+ }
2576
+ catch {
2577
+ return undefined;
2578
+ }
2579
+ }
2580
+ /** Start a classic channel instance with lightweight config */
2581
+ async startClassicInstance(instanceName, backend, preTaskCommand) {
2582
+ if (this.daemons.has(instanceName))
2583
+ return;
2584
+ const config = {
2585
+ ...DEFAULT_INSTANCE_CONFIG,
2586
+ ...this.fleetConfig?.defaults,
2587
+ working_directory: join(getAgendHome(), "workspaces", instanceName),
2588
+ lightweight: true,
2589
+ ...(backend ? { backend } : {}),
2590
+ ...(preTaskCommand ? { pre_task_command: preTaskCommand } : {}),
2591
+ };
2592
+ const topicMode = this.fleetConfig?.channel?.mode === "topic";
2593
+ await this.startInstance(instanceName, config, topicMode);
2594
+ }
2595
+ /** Handle /start slash command — register classic channel */
2596
+ async handleClassicStart(channelId, channelName, userId, guildId) {
2597
+ if (!this.classicChannels)
2598
+ return "Classic channel manager not initialized.";
2599
+ if (guildId && !this.classicChannels.isGuildAllowed(guildId))
2600
+ return "⛔ This server is not in the allowed guilds list.";
2601
+ if (this.classicChannels.isClassicChannel(channelId))
2602
+ return "This channel already has an active agent. Use /chat to talk.";
2603
+ if (this.routing.resolve(channelId))
2604
+ return "This channel is already bound to a topic-mode instance.";
2605
+ const instanceName = classicInstanceName(sanitizeInstanceName(channelName || channelId), channelId);
2606
+ this.classicChannels.register(channelId, instanceName, channelName || channelId, userId);
2607
+ this.routing.register(channelId, { kind: "classic", name: instanceName });
2608
+ await this.startClassicInstance(instanceName, this.classicChannels.getBackend(channelId, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channelId));
2609
+ this.reregisterClassicChannels();
2610
+ this.logger.info({ channelId, instanceName, userId }, "Classic channel started");
2611
+ return `✅ Agent started in this channel. Use \`/chat <message>\` to talk.`;
2612
+ }
2613
+ /** Handle /stop slash command — unregister classic channel */
2614
+ async handleClassicStop(channelId) {
2615
+ if (!this.classicChannels)
2616
+ return "Classic channel manager not initialized.";
2617
+ const ch = this.classicChannels.unregister(channelId);
2618
+ if (!ch)
2619
+ return "No active agent in this channel.";
2620
+ this.routing.unregister(channelId);
2621
+ await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
2622
+ this.reregisterClassicChannels();
2623
+ this.logger.info({ channelId, instanceName: ch.instanceName }, "Classic channel stopped");
2624
+ return `🛑 Agent stopped in this channel.`;
2625
+ }
2626
+ async stopAll() {
2627
+ this.clearStatuslineWatchers();
2628
+ this.costGuard?.stop();
2629
+ this.dailySummary?.stop();
2630
+ if (this.topicCleanupTimer) {
2631
+ clearInterval(this.topicCleanupTimer);
2632
+ this.topicCleanupTimer = null;
2633
+ }
2634
+ if (this.sessionPruneTimer) {
2635
+ clearInterval(this.sessionPruneTimer);
2636
+ this.sessionPruneTimer = null;
2637
+ }
2638
+ if (this.mirrorTimer) {
2639
+ clearTimeout(this.mirrorTimer);
2640
+ this.mirrorTimer = null;
2641
+ this.mirrorBuffer = [];
2642
+ }
2643
+ if (this.classicReloadTimer) {
2644
+ clearInterval(this.classicReloadTimer);
2645
+ this.classicReloadTimer = null;
2646
+ }
2647
+ this.topicArchiver.stop();
2648
+ this.scheduler?.shutdown();
2649
+ // Stop instances sequentially to avoid tmux send-keys race conditions.
2650
+ // Each stop sends quit + Enter via separate tmux commands; parallel stops
2651
+ // can cause the Enter to arrive before the quit text is processed.
2652
+ for (const [name, daemon] of this.daemons) {
2653
+ try {
2654
+ await daemon.stop();
2655
+ }
2656
+ catch (err) {
2657
+ this.logger.warn({ name, err }, "Stop failed");
2658
+ }
2659
+ this.daemons.delete(name);
2660
+ }
2661
+ for (const [, ipc] of this.instanceIpcClients) {
2662
+ await ipc.close();
2663
+ }
2664
+ this.instanceIpcClients.clear();
2665
+ for (const [, w] of this.worlds) {
2666
+ await w.stop().catch(() => { });
2667
+ }
2668
+ this.adapter = null;
2669
+ this.worlds.clear();
2670
+ this.adapters.clear();
2671
+ this.controlClient?.stop();
2672
+ this.controlClient = null;
2673
+ if (this.healthServer) {
2674
+ this.healthServer.close();
2675
+ this.healthServer = null;
2676
+ }
2677
+ this.eventLog?.close();
2678
+ const pidPath = join(this.dataDir, "fleet.pid");
2679
+ try {
2680
+ unlinkSync(pidPath);
2681
+ }
2682
+ catch (e) {
2683
+ this.logger.debug({ err: e }, "Failed to remove fleet PID file");
2684
+ }
2685
+ }
2686
+ /**
2687
+ * Prune stale external sessions by re-querying each daemon for live sessions.
2688
+ * Sessions in the registry that are no longer reported by any daemon are removed.
2689
+ */
2690
+ async pruneStaleExternalSessions() {
2691
+ const liveSessions = new Set();
2692
+ // Ask each daemon for its currently connected external sessions
2693
+ const queries = [...this.instanceIpcClients.entries()].map(([_name, ipc]) => {
2694
+ if (!ipc.connected)
2695
+ return Promise.resolve();
2696
+ return new Promise((resolve) => {
2697
+ let settled = false;
2698
+ const finish = () => {
2699
+ if (settled)
2700
+ return;
2701
+ settled = true;
2702
+ clearTimeout(timeout);
2703
+ ipc.removeListener("message", handler);
2704
+ resolve();
2705
+ };
2706
+ const handler = (msg) => {
2707
+ if (msg.type !== "query_sessions_response")
2708
+ return;
2709
+ for (const s of msg.sessions)
2710
+ liveSessions.add(s);
2711
+ finish();
2712
+ };
2713
+ const timeout = setTimeout(finish, 5000);
2714
+ ipc.on("message", handler);
2715
+ ipc.send({ type: "query_sessions" });
2716
+ });
2717
+ });
2718
+ await Promise.all(queries);
2719
+ // Remove sessions not found in any daemon
2720
+ let pruned = 0;
2721
+ for (const [sessionName] of this.sessionRegistry) {
2722
+ if (!liveSessions.has(sessionName)) {
2723
+ this.sessionRegistry.delete(sessionName);
2724
+ this.logger.info({ sessionName }, "Pruned stale external session");
2725
+ pruned++;
2726
+ }
2727
+ }
2728
+ if (pruned > 0) {
2729
+ this.logger.info({ pruned, remaining: this.sessionRegistry.size }, "Session registry pruned");
2730
+ }
2731
+ return pruned;
2732
+ }
2733
+ /**
2734
+ * Graceful shutdown for full reload: wait for idle, notify, then stop everything.
2735
+ * The caller is expected to exit the process after this resolves.
2736
+ */
2737
+ async gracefulShutdownForReload() {
2738
+ const instanceNames = [...this.daemons.keys()];
2739
+ if (instanceNames.length === 0) {
2740
+ this.logger.info("No instances to stop");
2741
+ await this.stopAll();
2742
+ return;
2743
+ }
2744
+ this.logger.info(`Full restart: waiting for ${instanceNames.length} instances to idle...`);
2745
+ const groupId = this.fleetConfig?.channel?.group_id;
2746
+ if (groupId && this.adapter) {
2747
+ await this.adapter.sendText(String(groupId), `🔄 Full restart initiated — waiting for all instances to idle, then reloading process...`)
2748
+ .catch(e => this.logger.warn({ err: e }, "Failed to post full restart notification"));
2749
+ }
2750
+ // Wait for idle with 5-minute timeout
2751
+ const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
2752
+ let timeoutHandle;
2753
+ const idleDeadline = new Promise((_, reject) => {
2754
+ timeoutHandle = setTimeout(() => reject(new Error("Idle wait timed out after 5 minutes")), IDLE_TIMEOUT_MS);
2755
+ });
2756
+ try {
2757
+ await Promise.race([
2758
+ Promise.all(instanceNames.map(async (name) => {
2759
+ const daemon = this.daemons.get(name);
2760
+ if (daemon) {
2761
+ this.logger.info(`Waiting for ${name} to idle...`);
2762
+ await daemon.waitForIdle(10_000);
2763
+ this.logger.info(`${name} is idle`);
2764
+ }
2765
+ })),
2766
+ idleDeadline,
2767
+ ]);
2768
+ }
2769
+ catch (err) {
2770
+ this.logger.warn({ err }, "Idle wait timed out — force stopping");
2771
+ }
2772
+ finally {
2773
+ clearTimeout(timeoutHandle);
2774
+ }
2775
+ this.logger.info("All instances idle — stopping for reload...");
2776
+ await this.stopAll();
2777
+ // Clean up tmux session if no foreign windows remain
2778
+ try {
2779
+ const remaining = await TmuxManager.listWindows(getTmuxSession());
2780
+ if (remaining.length <= 1) {
2781
+ await TmuxManager.killSession(getTmuxSession());
2782
+ this.logger.info("Killed tmux session (clean)");
2783
+ }
2784
+ else {
2785
+ this.logger.warn({ remaining: remaining.map(w => w.name) }, "Windows remain after stopAll — skipping session kill");
2786
+ }
2787
+ }
2788
+ catch (err) {
2789
+ this.logger.debug({ err }, "Exit tmux session cleanup failed (best effort)");
2790
+ }
2791
+ }
2792
+ /**
2793
+ * Graceful restart: wait for all instances to be idle, then stop and start them.
2794
+ */
2795
+ /**
2796
+ * Hot-reload: re-read fleet.yaml and reconcile running instances.
2797
+ * Starts new, stops removed, restarts modified instances.
2798
+ * Fleet-level config (access, cost_guard, etc.) requires /restart to take effect.
2799
+ */
2800
+ async reconcileInstances() {
2801
+ if (!this.configPath)
2802
+ return;
2803
+ const oldConfig = this.fleetConfig;
2804
+ this.loadConfig(this.configPath);
2805
+ this.routing.rebuild(this.fleetConfig);
2806
+ this.reregisterClassicChannels();
2807
+ this.scheduler?.reload();
2808
+ const newInstances = this.fleetConfig.instances;
2809
+ const topicMode = this.fleetConfig?.channel?.mode === "topic";
2810
+ // Detect fleet-level config changes and warn
2811
+ const oldFleetLevel = JSON.stringify({ channel: oldConfig?.channel, defaults: oldConfig?.defaults });
2812
+ const newFleetLevel = JSON.stringify({ channel: this.fleetConfig?.channel, defaults: this.fleetConfig?.defaults });
2813
+ if (oldFleetLevel !== newFleetLevel) {
2814
+ this.logger.warn("Fleet-level config changed (channel/defaults) — use /restart for full effect");
2815
+ }
2816
+ // Stop removed instances (skip classic bot instances — they're managed by classicBot.yaml)
2817
+ const classicNames = new Set(this.classicChannels?.getAll().map(ch => ch.instanceName) ?? []);
2818
+ for (const name of this.daemons.keys()) {
2819
+ if (!(name in newInstances) && !classicNames.has(name)) {
2820
+ this.logger.info({ name }, "Instance removed from config — stopping");
2821
+ await this.stopInstance(name).catch(err => this.logger.error({ err, name }, "Failed to stop removed instance"));
2822
+ }
2823
+ }
2824
+ // Start new + restart modified instances
2825
+ for (const [name, config] of Object.entries(newInstances)) {
2826
+ if (!this.daemons.has(name)) {
2827
+ // New instance — startInstance already calls connectIpcToInstance
2828
+ this.logger.info({ name }, "New instance in config — starting");
2829
+ await this.startInstance(name, config, topicMode).catch(err => this.logger.error({ err, name }, "Failed to start new instance"));
2830
+ }
2831
+ else if (oldConfig?.instances[name]) {
2832
+ // Restart if any config field changed
2833
+ if (JSON.stringify(oldConfig.instances[name]) !== JSON.stringify(config)) {
2834
+ this.logger.info({ name }, "Instance config changed — restarting");
2835
+ await this.stopInstance(name).catch(() => { });
2836
+ await this.startInstance(name, config, topicMode).catch(err => this.logger.error({ err, name }, "Failed to restart modified instance"));
2837
+ }
2838
+ }
2839
+ }
2840
+ this.logger.info({ running: this.daemons.size, configured: Object.keys(newInstances).length }, "Reconcile complete");
2841
+ }
2842
+ async restartInstances() {
2843
+ if (!this.configPath) {
2844
+ this.logger.error("Cannot restart: no config path (was startAll called?)");
2845
+ return;
2846
+ }
2847
+ const instanceNames = [...this.daemons.keys()];
2848
+ if (instanceNames.length === 0) {
2849
+ this.logger.info("No instances to restart");
2850
+ return;
2851
+ }
2852
+ this.logger.info(`Graceful restart: waiting for ${instanceNames.length} instances to idle...`);
2853
+ const groupId = this.fleetConfig?.channel?.group_id;
2854
+ const generalName = this.findGeneralInstance();
2855
+ const generalThreadId = generalName ? this.fleetConfig?.instances[generalName]?.topic_id : undefined;
2856
+ const notifyOpts = { threadId: generalThreadId != null ? String(generalThreadId) : undefined };
2857
+ if (groupId && this.adapter) {
2858
+ await this.adapter.sendText(String(groupId), `🔄 Graceful restart initiated — waiting for all instances to idle...`, notifyOpts)
2859
+ .catch(e => this.logger.warn({ err: e }, "Failed to post restart notification"));
2860
+ }
2861
+ const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
2862
+ let timeoutHandle;
2863
+ const idleDeadline = new Promise((_, reject) => {
2864
+ timeoutHandle = setTimeout(() => reject(new Error("Idle wait timed out after 5 minutes")), IDLE_TIMEOUT_MS);
2865
+ });
2866
+ try {
2867
+ await Promise.race([
2868
+ Promise.all(instanceNames.map(async (name) => {
2869
+ const daemon = this.daemons.get(name);
2870
+ if (daemon) {
2871
+ this.logger.info(`Waiting for ${name} to idle...`);
2872
+ await daemon.waitForIdle(10_000);
2873
+ this.logger.info(`${name} is idle`);
2874
+ }
2875
+ })),
2876
+ idleDeadline,
2877
+ ]);
2878
+ }
2879
+ catch (err) {
2880
+ this.logger.warn({ err }, "Idle wait timed out — force restarting");
2881
+ }
2882
+ finally {
2883
+ clearTimeout(timeoutHandle);
2884
+ }
2885
+ this.logger.info("All instances idle — restarting...");
2886
+ this.clearStatuslineWatchers();
2887
+ for (const [, ipc] of this.instanceIpcClients) {
2888
+ await ipc.close();
2889
+ }
2890
+ this.instanceIpcClients.clear();
2891
+ await Promise.allSettled(instanceNames.map(name => this.stopInstance(name)));
2892
+ // Kill remaining orphan windows to prevent stale state on restart
2893
+ try {
2894
+ const agendNames = new Set(instanceNames);
2895
+ agendNames.add("general");
2896
+ const existingWindows = await TmuxManager.listWindows(getTmuxSession());
2897
+ for (const w of existingWindows) {
2898
+ if (agendNames.has(w.name)) {
2899
+ const tm = new TmuxManager(getTmuxSession(), w.id);
2900
+ await tm.killWindow();
2901
+ }
2902
+ }
2903
+ }
2904
+ catch (err) {
2905
+ this.logger.debug({ err }, "Restart tmux window cleanup failed (best effort)");
2906
+ }
2907
+ const fleet = this.loadConfig(this.configPath);
2908
+ this.fleetConfig = fleet;
2909
+ const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
2910
+ await this.startInstancesWithConcurrency(Object.entries(fleet.instances), topicMode);
2911
+ if (topicMode) {
2912
+ this.routing.rebuild(this.fleetConfig);
2913
+ this.reregisterClassicChannels();
2914
+ // startInstance already calls connectIpcToInstance, no need for connectToInstances here
2915
+ // Restart classic channel instances (killed during orphan cleanup)
2916
+ if (this.classicChannels) {
2917
+ const fleetBackend = this.fleetConfig?.defaults?.backend;
2918
+ const channels = this.classicChannels.getAll();
2919
+ const concurrency = 3;
2920
+ let idx = 0;
2921
+ while (idx < channels.length) {
2922
+ const batch = channels.slice(idx, idx + concurrency);
2923
+ await Promise.allSettled(batch.map(ch => this.startClassicInstance(ch.instanceName, this.classicChannels.getBackendByInstance(ch.instanceName, fleetBackend)).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to start classic instance"))));
2924
+ idx += concurrency;
2925
+ }
2926
+ }
2927
+ for (const name of Object.keys(fleet.instances)) {
2928
+ this.startStatuslineWatcher(name);
2929
+ }
2930
+ }
2931
+ this.logger.info("Graceful restart complete");
2932
+ if (groupId && this.adapter) {
2933
+ const total = Object.keys(fleet.instances).length;
2934
+ const started = this.daemons.size;
2935
+ const failedNames = Object.keys(fleet.instances).filter(n => !this.daemons.has(n));
2936
+ const restartText = failedNames.length === 0
2937
+ ? `Fleet ready. ${started}/${total} instances running.`
2938
+ : `Fleet ready. ${started}/${total} instances running. Failed: ${failedNames.join(", ")}`;
2939
+ await this.adapter.sendText(String(groupId), restartText, notifyOpts)
2940
+ .catch(e => this.logger.warn({ err: e }, "Failed to post restart completion notification"));
2941
+ // Notify each instance's channel — staggered to avoid rate limit storm
2942
+ const instances = Object.entries(this.fleetConfig?.instances ?? {});
2943
+ this.logger.info({ count: instances.length }, "Sending restart notification to instances (staggered)");
2944
+ const BATCH_SIZE = 3;
2945
+ const BATCH_DELAY_MS = 2500;
2946
+ for (let i = 0; i < instances.length; i += BATCH_SIZE) {
2947
+ if (i > 0)
2948
+ await new Promise(r => setTimeout(r, BATCH_DELAY_MS));
2949
+ const batch = instances.slice(i, i + BATCH_SIZE);
2950
+ for (const [name, config] of batch) {
2951
+ const threadId = config.topic_id != null ? String(config.topic_id) : undefined;
2952
+ const daemon = this.daemons.get(name);
2953
+ const isNewSession = daemon?.isNewSession ?? false;
2954
+ const msg = isNewSession
2955
+ ? "Fleet restart complete. Configuration changed — starting fresh session."
2956
+ : "Fleet restart complete. Continue from where you left off.";
2957
+ if (threadId) {
2958
+ this.adapter.sendText(String(groupId), msg, { threadId })
2959
+ .catch(e => this.logger.warn({ err: e, name, threadId }, "Failed to post per-instance restart notification"));
2960
+ }
2961
+ const ipc = this.instanceIpcClients.get(name);
2962
+ if (ipc?.connected) {
2963
+ ipc.send({
2964
+ type: "fleet_inbound",
2965
+ content: msg,
2966
+ meta: {
2967
+ chat_id: String(groupId),
2968
+ thread_id: threadId ?? "",
2969
+ ts: new Date().toISOString(),
2970
+ },
2971
+ });
2972
+ }
2973
+ }
2974
+ }
2975
+ }
2976
+ }
2977
+ // ── Health HTTP endpoint ─────────────────────────────────────────────
2978
+ startHealthServer(port) {
2979
+ this.startedAt = Date.now();
2980
+ // Generate web token before server starts so auth is enforced from the first request.
2981
+ this.webToken = randomBytes(24).toString("hex");
2982
+ const tokenPath = join(this.dataDir, "web.token");
2983
+ writeFileSync(tokenPath, this.webToken, { mode: 0o600 });
2984
+ // Defensive: if file existed previously with looser perms, tighten it.
2985
+ try {
2986
+ chmodSync(tokenPath, 0o600);
2987
+ }
2988
+ catch {
2989
+ // best-effort
2990
+ }
2991
+ this.healthServer = createServer((req, res) => {
2992
+ res.setHeader("Content-Type", "application/json");
2993
+ // Public health probe — no auth required.
2994
+ if (req.method === "GET" && req.url === "/health") {
2995
+ // fallthrough to existing handler below
2996
+ }
2997
+ else {
2998
+ // All other endpoints require a valid token (query ?token= or X-Agend-Token header).
2999
+ // /ui/* will also re-check in web-api.ts, which is harmless.
3000
+ const parsedUrl = new URL(req.url ?? "/", `http://localhost:${port}`);
3001
+ const headerToken = req.headers["x-agend-token"];
3002
+ const providedToken = parsedUrl.searchParams.get("token")
3003
+ ?? (typeof headerToken === "string" ? headerToken : null);
3004
+ if (!this.webToken || providedToken !== this.webToken) {
3005
+ res.writeHead(401);
3006
+ res.end(JSON.stringify({ error: "Unauthorized" }));
3007
+ return;
3008
+ }
3009
+ }
3010
+ if (req.method === "GET" && req.url === "/health") {
3011
+ const instanceCount = this.fleetConfig?.instances
3012
+ ? Object.keys(this.fleetConfig.instances).length
3013
+ : 0;
3014
+ res.writeHead(200);
3015
+ res.end(JSON.stringify({
3016
+ status: "ok",
3017
+ instances: instanceCount,
3018
+ uptime: Math.floor((Date.now() - this.startedAt) / 1000),
3019
+ }));
3020
+ return;
3021
+ }
3022
+ if (req.method === "GET" && req.url === "/status") {
3023
+ const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => {
3024
+ const statusFile = join(this.getInstanceDir(name), "statusline.json");
3025
+ let context_pct = 0;
3026
+ let cost = 0;
3027
+ try {
3028
+ const data = JSON.parse(readFileSync(statusFile, "utf-8"));
3029
+ context_pct = data.context_window?.used_percentage ?? 0;
3030
+ cost = data.cost?.total_cost_usd ?? 0;
3031
+ }
3032
+ catch (err) {
3033
+ this.logger.debug({ err, name }, "statusline.json read failed (/status)");
3034
+ }
3035
+ return {
3036
+ name,
3037
+ status: this.getInstanceStatus(name),
3038
+ context_pct,
3039
+ cost,
3040
+ };
3041
+ });
3042
+ res.writeHead(200);
3043
+ res.end(JSON.stringify({ instances }));
3044
+ return;
3045
+ }
3046
+ // Fleet API (enriched for agent board)
3047
+ if (req.method === "GET" && req.url === "/api/fleet") {
3048
+ const sysInfo = this.getSysInfo();
3049
+ const enriched = sysInfo.instances.map(inst => {
3050
+ const config = this.fleetConfig?.instances[inst.name];
3051
+ // Find claimed tasks for this instance
3052
+ let currentTask = null;
3053
+ try {
3054
+ const tasks = this.scheduler?.db.listTasks({ assignee: inst.name, status: "claimed" });
3055
+ if (tasks?.length)
3056
+ currentTask = tasks[0].title;
3057
+ }
3058
+ catch (err) {
3059
+ this.logger.debug({ err, name: inst.name }, "Scheduler listTasks failed (/api/fleet)");
3060
+ }
3061
+ return {
3062
+ ...inst,
3063
+ description: config?.description ?? null,
3064
+ backend: config?.backend ?? "claude-code",
3065
+ tool_set: config?.tool_set ?? "full",
3066
+ general_topic: config?.general_topic ?? false,
3067
+ lastActivity: this.lastActivityMs(inst.name) || null,
3068
+ currentTask,
3069
+ };
3070
+ });
3071
+ res.setHeader("Access-Control-Allow-Origin", "*");
3072
+ res.writeHead(200);
3073
+ res.end(JSON.stringify({
3074
+ ...sysInfo,
3075
+ instances: enriched,
3076
+ }));
3077
+ return;
3078
+ }
3079
+ // Activity API
3080
+ if (req.method === "GET" && req.url?.startsWith("/api/activity")) {
3081
+ const url = new URL(req.url, `http://localhost:${port}`);
3082
+ const sinceParam = url.searchParams.get("since") ?? "2h";
3083
+ const limitParam = url.searchParams.get("limit") ?? "500";
3084
+ const match = sinceParam.match(/^(\d+)(m|h|d)$/);
3085
+ let sinceIso;
3086
+ if (match) {
3087
+ const val = parseInt(match[1], 10);
3088
+ const unit = match[2] === "d" ? 86400000 : match[2] === "h" ? 3600000 : 60000;
3089
+ sinceIso = new Date(Date.now() - val * unit).toISOString();
3090
+ }
3091
+ const rows = this.eventLog?.listActivity({ since: sinceIso, limit: parseInt(limitParam, 10) }) ?? [];
3092
+ res.setHeader("Access-Control-Allow-Origin", "*");
3093
+ res.writeHead(200);
3094
+ res.end(JSON.stringify(rows));
3095
+ return;
3096
+ }
3097
+ // Activity viewer
3098
+ if (req.method === "GET" && (req.url === "/activity" || req.url === "/activity/")) {
3099
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
3100
+ res.writeHead(200);
3101
+ res.end(ACTIVITY_VIEWER_HTML);
3102
+ return;
3103
+ }
3104
+ // Instance start via API
3105
+ if (req.method === "POST" && req.url?.startsWith("/api/instance/") && req.url.endsWith("/start")) {
3106
+ const name = decodeURIComponent(req.url.slice("/api/instance/".length, -"/start".length));
3107
+ const config = this.fleetConfig?.instances[name];
3108
+ if (!config) {
3109
+ res.writeHead(404);
3110
+ res.end(JSON.stringify({ error: `Instance not found: ${name}` }));
3111
+ return;
3112
+ }
3113
+ (async () => {
3114
+ try {
3115
+ const topicMode = this.fleetConfig?.channel?.mode === "topic";
3116
+ await this.startInstance(name, config, topicMode ?? false);
3117
+ this.emitSseEvent("status", this.getUiStatus());
3118
+ res.writeHead(200);
3119
+ res.end(JSON.stringify({ ok: true }));
3120
+ }
3121
+ catch (err) {
3122
+ res.writeHead(500);
3123
+ res.end(JSON.stringify({ error: `Start failed: ${err.message}` }));
3124
+ }
3125
+ })();
3126
+ return;
3127
+ }
3128
+ // Instance restart (immediate, no idle wait)
3129
+ if (req.method === "POST" && req.url?.startsWith("/restart/")) {
3130
+ const name = decodeURIComponent(req.url.slice("/restart/".length));
3131
+ this.logger.info({ name }, "Instance restart requested via HTTP");
3132
+ (async () => {
3133
+ try {
3134
+ await this.restartSingleInstance(name);
3135
+ this.logger.info({ name }, "Instance restarted");
3136
+ this.emitSseEvent("status", this.getUiStatus());
3137
+ res.writeHead(200);
3138
+ res.end(JSON.stringify({ restarted: name }));
3139
+ }
3140
+ catch (err) {
3141
+ this.logger.error({ err, name }, "Instance restart failed");
3142
+ const status = err.message.includes("not found") ? 404 : 500;
3143
+ res.writeHead(status);
3144
+ res.end(JSON.stringify({ error: `Restart failed: ${err.message}` }));
3145
+ }
3146
+ })();
3147
+ return;
3148
+ }
3149
+ // ── Agent CLI endpoint ─────
3150
+ if (req.url === "/agent" && req.method === "POST") {
3151
+ handleAgentRequest(req, res, this);
3152
+ return;
3153
+ }
3154
+ // ── Web UI endpoints (delegated to web-api.ts) ─────
3155
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
3156
+ if (handleWebRequest(req, res, url, this))
3157
+ return;
3158
+ res.writeHead(404);
3159
+ res.end(JSON.stringify({ error: "not found" }));
3160
+ });
3161
+ this.healthServer.on("error", (err) => {
3162
+ if (err.code === "EADDRINUSE") {
3163
+ this.logger.warn({ port }, "Health port in use — attempting takeover");
3164
+ const pidPath = join(this.dataDir, "fleet.pid");
3165
+ try {
3166
+ if (existsSync(pidPath)) {
3167
+ const oldPid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
3168
+ if (oldPid && oldPid !== process.pid) {
3169
+ process.kill(oldPid, "SIGTERM");
3170
+ this.logger.info({ oldPid }, "Killed old fleet process");
3171
+ }
3172
+ }
3173
+ }
3174
+ catch (err) {
3175
+ this.logger.debug({ err }, "Old fleet process kill skipped (already gone or no permission)");
3176
+ }
3177
+ setTimeout(() => {
3178
+ if (!this.healthServer)
3179
+ return;
3180
+ this.healthServer.listen(port, "127.0.0.1", () => {
3181
+ this.logger.info({ port }, "Health endpoint listening (after takeover)");
3182
+ }).on("error", () => {
3183
+ this.logger.warn({ port }, "Health port still in use — skipping health endpoint");
3184
+ });
3185
+ }, 1500);
3186
+ return;
3187
+ }
3188
+ this.logger.error({ err, port }, "Health server error");
3189
+ });
3190
+ this.healthServer.listen(port, "127.0.0.1", () => {
3191
+ this.logger.info({ port }, "Health endpoint listening");
3192
+ });
3193
+ this.logger.info({ url: `http://localhost:${port}/ui?token=${this.webToken}` }, "Web UI available");
3194
+ }
3195
+ getUiStatus() {
3196
+ const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => {
3197
+ const statusFile = join(this.getInstanceDir(name), "statusline.json");
3198
+ let context_pct = 0;
3199
+ let cost = 0;
3200
+ let model = "";
3201
+ try {
3202
+ const data = JSON.parse(readFileSync(statusFile, "utf-8"));
3203
+ context_pct = data.context_window?.used_percentage ?? 0;
3204
+ cost = data.cost?.total_cost_usd ?? 0;
3205
+ model = data.model?.display_name ?? "";
3206
+ }
3207
+ catch (err) {
3208
+ this.logger.debug({ err, name }, "statusline.json read failed (getUiStatus)");
3209
+ }
3210
+ return { name, status: this.getInstanceStatus(name), context_pct, cost, model };
3211
+ });
3212
+ return {
3213
+ instances,
3214
+ uptime: Math.floor((Date.now() - this.startedAt) / 1000),
3215
+ };
3216
+ }
3217
+ }
3218
+ const ACTIVITY_VIEWER_HTML = `<!DOCTYPE html>
3219
+ <html lang="en">
3220
+ <head>
3221
+ <meta charset="utf-8">
3222
+ <meta name="viewport" content="width=device-width, initial-scale=1">
3223
+ <title>AgEnD Activity Viewer</title>
3224
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
3225
+ <style>
3226
+ * { margin: 0; padding: 0; box-sizing: border-box; }
3227
+ body { background: #0d1117; color: #c9d1d9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace; }
3228
+ .header { padding: 16px 24px; border-bottom: 1px solid #21262d; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
3229
+ .header h1 { font-size: 18px; color: #58a6ff; font-weight: 600; }
3230
+ .controls { display: flex; gap: 8px; align-items: center; }
3231
+ .controls select, .controls button { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; border-radius: 6px; padding: 4px 10px; font-size: 13px; cursor: pointer; }
3232
+ .controls button.active { background: #1f6feb; border-color: #1f6feb; color: #fff; }
3233
+ .controls button:hover { border-color: #58a6ff; }
3234
+ .speed-group { display: flex; gap: 2px; }
3235
+ .speed-group button { border-radius: 0; }
3236
+ .speed-group button:first-child { border-radius: 6px 0 0 6px; }
3237
+ .speed-group button:last-child { border-radius: 0 6px 6px 0; }
3238
+ .status { font-size: 12px; color: #8b949e; margin-left: auto; }
3239
+ #diagram { padding: 24px; overflow-x: auto; }
3240
+ #diagram .mermaid { background: transparent; }
3241
+ #diagram svg { max-width: 100%; }
3242
+ .feed { padding: 12px 24px; max-height: 300px; overflow-y: auto; border-top: 1px solid #21262d; font-size: 13px; line-height: 1.8; }
3243
+ .feed-line { opacity: 0.6; }
3244
+ .feed-line.visible { opacity: 1; }
3245
+ .feed-line .time { color: #8b949e; }
3246
+ .feed-line .msg { color: #58a6ff; }
3247
+ .feed-line .tool { color: #d29922; }
3248
+ .feed-line .task { color: #3fb950; }
3249
+ /* Agent Board */
3250
+ .board { padding: 16px 24px; display: flex; gap: 12px; flex-wrap: wrap; border-bottom: 1px solid #21262d; }
3251
+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 12px 14px; min-width: 200px; flex: 1; max-width: 280px; transition: border-color 0.3s; }
3252
+ .card.flash { border-color: #58a6ff; }
3253
+ .card-header { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
3254
+ .card-header .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
3255
+ .card-header .dot.running { background: #3fb950; }
3256
+ .card-header .dot.stopped { background: #8b949e; }
3257
+ .card-header .dot.crashed { background: #f85149; }
3258
+ .card-header .name { font-weight: 600; font-size: 14px; }
3259
+ .card-row { font-size: 12px; color: #8b949e; line-height: 1.6; }
3260
+ .card-row span { color: #c9d1d9; }
3261
+ .card-task { font-size: 12px; color: #d29922; margin-top: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3262
+ .board-empty { font-size: 13px; color: #8b949e; padding: 8px 0; }
3263
+ .section-label { font-size: 11px; color: #484f58; text-transform: uppercase; letter-spacing: 1px; padding: 10px 24px 0; }
3264
+ .tabs { display: flex; gap: 0; padding: 0 24px; border-bottom: 1px solid #21262d; }
3265
+ .tab { padding: 8px 16px; font-size: 13px; color: #8b949e; cursor: pointer; border: none; border-bottom: 2px solid transparent; background: none; }
3266
+ .tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }
3267
+ .tab:hover { color: #c9d1d9; }
3268
+ .view { display: none; }
3269
+ .view.active { display: block; }
3270
+ #graphCanvas { width: 100%; background: #0d1117; display: block; }
3271
+ </style>
3272
+ </head>
3273
+ <body>
3274
+ <div class="header">
3275
+ <h1>AgEnD Activity</h1>
3276
+ <div class="controls">
3277
+ <select id="range">
3278
+ <option value="1h">1h</option>
3279
+ <option value="2h" selected>2h</option>
3280
+ <option value="4h">4h</option>
3281
+ <option value="8h">8h</option>
3282
+ <option value="24h">24h</option>
3283
+ </select>
3284
+ <button id="btnLoad">Load</button>
3285
+ <button id="btnPlay">▶ Play</button>
3286
+ <button id="btnPause" style="display:none">⏸ Pause</button>
3287
+ <div class="speed-group">
3288
+ <button class="speed" data-speed="1">1x</button>
3289
+ <button class="speed active" data-speed="2">2x</button>
3290
+ <button class="speed" data-speed="5">5x</button>
3291
+ <button class="speed" data-speed="10">10x</button>
3292
+ </div>
3293
+ </div>
3294
+ <div class="status" id="status">Ready</div>
3295
+ </div>
3296
+ <div class="section-label">Agents</div>
3297
+ <div class="board" id="board"><div class="board-empty">Loading...</div></div>
3298
+ <div class="tabs">
3299
+ <button class="tab active" data-view="graph">Network Graph</button>
3300
+ <button class="tab" data-view="seq">Sequence Diagram</button>
3301
+ </div>
3302
+ <div id="viewGraph" class="view active"><canvas id="graphCanvas" height="400"></canvas></div>
3303
+ <div id="viewSeq" class="view"><div id="diagram"><div class="mermaid" id="mermaidEl"></div></div></div>
3304
+ <div class="feed" id="feed"></div>
3305
+
3306
+ <script>
3307
+ mermaid.initialize({ startOnLoad: false, theme: 'dark', sequence: { mirrorActors: false, messageAlign: 'left' } });
3308
+
3309
+ let rows = [];
3310
+ let speed = 2;
3311
+ let playing = false;
3312
+ let playTimeout = null;
3313
+ let visibleCount = 0;
3314
+
3315
+ document.querySelectorAll('.speed').forEach(btn => {
3316
+ btn.addEventListener('click', () => {
3317
+ document.querySelectorAll('.speed').forEach(b => b.classList.remove('active'));
3318
+ btn.classList.add('active');
3319
+ speed = parseInt(btn.dataset.speed);
3320
+ });
3321
+ });
3322
+
3323
+ document.getElementById('btnLoad').addEventListener('click', load);
3324
+ document.getElementById('btnPlay').addEventListener('click', startReplay);
3325
+ document.getElementById('btnPause').addEventListener('click', pauseReplay);
3326
+
3327
+ async function load() {
3328
+ const range = document.getElementById('range').value;
3329
+ document.getElementById('status').textContent = 'Loading...';
3330
+ try {
3331
+ const resp = await fetch('/api/activity?since=' + range + '&limit=500');
3332
+ rows = await resp.json();
3333
+ document.getElementById('status').textContent = rows.length + ' events loaded';
3334
+ visibleCount = rows.length;
3335
+ renderFull();
3336
+ } catch (e) {
3337
+ document.getElementById('status').textContent = 'Error: ' + e.message;
3338
+ }
3339
+ }
3340
+
3341
+ function buildMermaid(entries) {
3342
+ const participants = new Set();
3343
+ entries.forEach(r => { participants.add(r.sender); if (r.receiver) participants.add(r.receiver); });
3344
+ const aliases = new Map();
3345
+ let idx = 0;
3346
+ participants.forEach(p => {
3347
+ const a = p.length > 12 ? String.fromCharCode(65 + idx++) : p;
3348
+ aliases.set(p, a);
3349
+ });
3350
+
3351
+ let lines = ['sequenceDiagram'];
3352
+ aliases.forEach((a, p) => lines.push(' participant ' + a + ' as ' + p));
3353
+
3354
+ entries.forEach(r => {
3355
+ const s = aliases.get(r.sender) || r.sender;
3356
+ const summary = (r.summary || '').replace(/"/g, "'").slice(0, 80);
3357
+ if (r.event === 'tool_call') {
3358
+ lines.push(' Note over ' + s + ': 🔧 ' + summary);
3359
+ } else if (r.receiver) {
3360
+ const recv = aliases.get(r.receiver) || r.receiver;
3361
+ lines.push(' ' + s + '->>' + recv + ': ' + summary);
3362
+ } else {
3363
+ lines.push(' Note over ' + s + ': ' + summary);
3364
+ }
3365
+ });
3366
+ return lines.join('\\n');
3367
+ }
3368
+
3369
+ async function renderDiagram(entries) {
3370
+ const code = buildMermaid(entries);
3371
+ const el = document.getElementById('mermaidEl');
3372
+ el.removeAttribute('data-processed');
3373
+ el.innerHTML = code;
3374
+ try { await mermaid.run({ nodes: [el] }); } catch {}
3375
+ }
3376
+
3377
+ function renderFeed(count) {
3378
+ const feed = document.getElementById('feed');
3379
+ feed.innerHTML = '';
3380
+ rows.forEach((r, i) => {
3381
+ const vis = i < count;
3382
+ const time = (r.timestamp || '').replace('T', ' ').slice(11, 19);
3383
+ const icon = r.event === 'message' ? '💬' : r.event === 'tool_call' ? '🔧' : '📋';
3384
+ const cls = r.event === 'tool_call' ? 'tool' : r.event === 'task_update' ? 'task' : 'msg';
3385
+ const arrow = r.receiver ? r.sender + ' → ' + r.receiver : r.sender;
3386
+ const line = document.createElement('div');
3387
+ line.className = 'feed-line' + (vis ? ' visible' : '');
3388
+ line.innerHTML = '<span class="time">' + time + '</span> ' + icon + ' <span class="' + cls + '">' + arrow + ': ' + (r.summary || '') + '</span>';
3389
+ feed.appendChild(line);
3390
+ });
3391
+ if (count > 0) feed.lastElementChild?.scrollIntoView({ behavior: 'smooth' });
3392
+ }
3393
+
3394
+ function renderFull() {
3395
+ visibleCount = rows.length;
3396
+ renderDiagram(rows);
3397
+ renderFeed(rows.length);
3398
+ }
3399
+
3400
+ function startReplay() {
3401
+ playing = true;
3402
+ visibleCount = 0;
3403
+ document.getElementById('btnPlay').style.display = 'none';
3404
+ document.getElementById('btnPause').style.display = '';
3405
+ stepReplay();
3406
+ }
3407
+
3408
+ function pauseReplay() {
3409
+ playing = false;
3410
+ if (playTimeout) clearTimeout(playTimeout);
3411
+ document.getElementById('btnPlay').style.display = '';
3412
+ document.getElementById('btnPause').style.display = 'none';
3413
+ }
3414
+
3415
+ function stepReplay() {
3416
+ if (!playing || visibleCount >= rows.length) {
3417
+ pauseReplay();
3418
+ document.getElementById('status').textContent = 'Replay complete';
3419
+ return;
3420
+ }
3421
+ visibleCount++;
3422
+ const visible = rows.slice(0, visibleCount);
3423
+ renderDiagram(visible);
3424
+ renderFeed(visibleCount);
3425
+ document.getElementById('status').textContent = visibleCount + '/' + rows.length;
3426
+
3427
+ // Calculate delay from real timestamps
3428
+ let delayMs = 500;
3429
+ if (visibleCount < rows.length) {
3430
+ const curr = new Date(rows[visibleCount - 1].timestamp).getTime();
3431
+ const next = new Date(rows[visibleCount].timestamp).getTime();
3432
+ delayMs = Math.max(100, Math.min(3000, (next - curr) / speed));
3433
+ }
3434
+ playTimeout = setTimeout(stepReplay, delayMs);
3435
+ }
3436
+
3437
+ // ── Tab switching ────────────────────────────────
3438
+ document.querySelectorAll('.tab').forEach(tab => {
3439
+ tab.addEventListener('click', () => {
3440
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
3441
+ document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
3442
+ tab.classList.add('active');
3443
+ document.getElementById('view' + (tab.dataset.view === 'graph' ? 'Graph' : 'Seq')).classList.add('active');
3444
+ if (tab.dataset.view === 'graph') resizeCanvas();
3445
+ });
3446
+ });
3447
+
3448
+ // ── Network Graph ────────────────────────────────
3449
+ const canvas = document.getElementById('graphCanvas');
3450
+ const ctx2d = canvas.getContext('2d');
3451
+ let graphNodes = []; // {name, x, y, color, isGeneral}
3452
+ let graphEdges = new Map(); // "a->b" → {from, to}
3453
+ let pulses = []; // {fromX, fromY, toX, toY, progress, color}
3454
+
3455
+ function resizeCanvas() {
3456
+ canvas.width = canvas.parentElement.offsetWidth;
3457
+ canvas.height = 400;
3458
+ layoutNodes();
3459
+ }
3460
+
3461
+ function layoutNodes() {
3462
+ if (graphNodes.length === 0) return;
3463
+ const cx = canvas.width / 2;
3464
+ const cy = canvas.height / 2;
3465
+ const radius = Math.min(cx, cy) - 60;
3466
+ // Find general (center)
3467
+ const general = graphNodes.find(n => n.isGeneral);
3468
+ const others = graphNodes.filter(n => !n.isGeneral);
3469
+ if (general) { general.x = cx; general.y = cy; }
3470
+ others.forEach((n, i) => {
3471
+ const angle = (2 * Math.PI * i / others.length) - Math.PI / 2;
3472
+ n.x = cx + radius * Math.cos(angle);
3473
+ n.y = cy + radius * Math.sin(angle);
3474
+ });
3475
+ }
3476
+
3477
+ function updateGraphFromFleet(data) {
3478
+ const names = new Set();
3479
+ data.instances.forEach(inst => names.add(inst.name));
3480
+ // Add user node if activity mentions it
3481
+ rows.forEach(r => { names.add(r.sender); if (r.receiver) names.add(r.receiver); });
3482
+ // Rebuild nodes (preserve positions if same set)
3483
+ const oldMap = new Map(graphNodes.map(n => [n.name, n]));
3484
+ graphNodes = [...names].map(name => {
3485
+ const old = oldMap.get(name);
3486
+ const inst = data.instances.find(i => i.name === name);
3487
+ const color = !inst ? '#8b949e' : inst.status === 'running' ? '#3fb950' : inst.status === 'crashed' ? '#f85149' : '#484f58';
3488
+ return { name, x: old?.x ?? 0, y: old?.y ?? 0, color, isGeneral: inst?.general_topic ?? false };
3489
+ });
3490
+ layoutNodes();
3491
+ // Build edges from activity
3492
+ graphEdges.clear();
3493
+ rows.forEach(r => {
3494
+ if (r.receiver && r.event === 'message') {
3495
+ const key = r.sender + '->' + r.receiver;
3496
+ graphEdges.set(key, { from: r.sender, to: r.receiver });
3497
+ }
3498
+ });
3499
+ }
3500
+
3501
+ function spawnPulse(sender, receiver, event) {
3502
+ const from = graphNodes.find(n => n.name === sender);
3503
+ const to = graphNodes.find(n => n.name === (receiver || sender));
3504
+ if (!from || !to) return;
3505
+ const colors = { message: '#58a6ff', tool_call: '#d29922', task_update: '#3fb950' };
3506
+ pulses.push({ fromX: from.x, fromY: from.y, toX: to.x, toY: to.y, progress: 0, color: colors[event] || '#58a6ff' });
3507
+ }
3508
+
3509
+ function drawGraph() {
3510
+ if (!ctx2d) return;
3511
+ ctx2d.clearRect(0, 0, canvas.width, canvas.height);
3512
+ // Draw edges
3513
+ ctx2d.strokeStyle = '#21262d';
3514
+ ctx2d.lineWidth = 1;
3515
+ graphEdges.forEach(e => {
3516
+ const from = graphNodes.find(n => n.name === e.from);
3517
+ const to = graphNodes.find(n => n.name === e.to);
3518
+ if (from && to) {
3519
+ ctx2d.beginPath();
3520
+ ctx2d.moveTo(from.x, from.y);
3521
+ ctx2d.lineTo(to.x, to.y);
3522
+ ctx2d.stroke();
3523
+ }
3524
+ });
3525
+ // Draw pulses
3526
+ pulses = pulses.filter(p => p.progress <= 1);
3527
+ pulses.forEach(p => {
3528
+ p.progress += 0.02;
3529
+ const x = p.fromX + (p.toX - p.fromX) * p.progress;
3530
+ const y = p.fromY + (p.toY - p.fromY) * p.progress;
3531
+ ctx2d.beginPath();
3532
+ ctx2d.arc(x, y, 5, 0, Math.PI * 2);
3533
+ ctx2d.fillStyle = p.color;
3534
+ ctx2d.shadowColor = p.color;
3535
+ ctx2d.shadowBlur = 12;
3536
+ ctx2d.fill();
3537
+ ctx2d.shadowBlur = 0;
3538
+ });
3539
+ // Draw nodes
3540
+ graphNodes.forEach(n => {
3541
+ // Glow
3542
+ ctx2d.beginPath();
3543
+ ctx2d.arc(n.x, n.y, n.isGeneral ? 28 : 22, 0, Math.PI * 2);
3544
+ ctx2d.fillStyle = n.color + '22';
3545
+ ctx2d.fill();
3546
+ // Circle
3547
+ ctx2d.beginPath();
3548
+ ctx2d.arc(n.x, n.y, n.isGeneral ? 24 : 18, 0, Math.PI * 2);
3549
+ ctx2d.fillStyle = '#161b22';
3550
+ ctx2d.strokeStyle = n.color;
3551
+ ctx2d.lineWidth = 2;
3552
+ ctx2d.fill();
3553
+ ctx2d.stroke();
3554
+ // Label
3555
+ ctx2d.fillStyle = '#c9d1d9';
3556
+ ctx2d.font = (n.isGeneral ? '12' : '11') + 'px -apple-system, monospace';
3557
+ ctx2d.textAlign = 'center';
3558
+ ctx2d.fillText(n.name.length > 14 ? n.name.slice(0, 12) + '..' : n.name, n.x, n.y + (n.isGeneral ? 38 : 32));
3559
+ });
3560
+ requestAnimationFrame(drawGraph);
3561
+ }
3562
+
3563
+ // Hook into replay: spawn pulses when stepping
3564
+ const origStep = stepReplay;
3565
+ stepReplay = function() {
3566
+ const prevCount = visibleCount;
3567
+ origStep();
3568
+ if (visibleCount > prevCount && visibleCount <= rows.length) {
3569
+ const r = rows[visibleCount - 1];
3570
+ spawnPulse(r.sender, r.receiver, r.event);
3571
+ }
3572
+ };
3573
+
3574
+ // Hook into full load: spawn pulses for all visible events on load
3575
+ const origRenderFull = renderFull;
3576
+ renderFull = function() {
3577
+ origRenderFull();
3578
+ // Update graph nodes from fleet data (if available)
3579
+ fetch('/api/fleet').then(r => r.json()).then(data => {
3580
+ updateGraphFromFleet(data);
3581
+ }).catch(() => {
3582
+ // Fallback: build nodes from activity only
3583
+ const names = new Set();
3584
+ rows.forEach(r => { names.add(r.sender); if (r.receiver) names.add(r.receiver); });
3585
+ graphNodes = [...names].map(n => ({ name: n, x: 0, y: 0, color: '#8b949e', isGeneral: n === 'general' }));
3586
+ layoutNodes();
3587
+ });
3588
+ };
3589
+
3590
+ resizeCanvas();
3591
+ window.addEventListener('resize', resizeCanvas);
3592
+ requestAnimationFrame(drawGraph);
3593
+
3594
+ // ── Agent Board ──────────────────────────────────
3595
+
3596
+ let prevBoard = '';
3597
+
3598
+ async function loadBoard() {
3599
+ try {
3600
+ const resp = await fetch('/api/fleet');
3601
+ const data = await resp.json();
3602
+ renderBoard(data);
3603
+ } catch {}
3604
+ }
3605
+
3606
+ function renderBoard(data) {
3607
+ const board = document.getElementById('board');
3608
+ const cards = data.instances.map(inst => {
3609
+ const statusDot = inst.status === 'running' ? 'running' : inst.status === 'crashed' ? 'crashed' : 'stopped';
3610
+ const icon = inst.status === 'running' ? '🟢' : inst.status === 'crashed' ? '🔴' : '⚪';
3611
+ const role = inst.general_topic ? 'coordinator' : inst.description || 'worker';
3612
+ const costStr = '$' + (inst.costCents / 100).toFixed(2);
3613
+ const lastMs = inst.lastActivity;
3614
+ let lastStr = '—';
3615
+ if (lastMs) {
3616
+ const ago = Math.floor((Date.now() - lastMs) / 1000);
3617
+ lastStr = ago < 60 ? ago + 's ago' : ago < 3600 ? Math.floor(ago/60) + 'm ago' : Math.floor(ago/3600) + 'h ago';
3618
+ }
3619
+ const ipc = inst.ipc ? '✓' : '✗';
3620
+ const rl = inst.rateLimits ? ' · 5h:' + inst.rateLimits.five_hour_pct + '%' : '';
3621
+ const taskLine = inst.currentTask
3622
+ ? '<div class="card-task">📌 ' + inst.currentTask + '</div>'
3623
+ : '<div class="card-task" style="color:#484f58">(idle)</div>';
3624
+ return '<div class="card" data-name="' + inst.name + '">' +
3625
+ '<div class="card-header"><div class="dot ' + statusDot + '"></div><div class="name">' + inst.name + '</div></div>' +
3626
+ '<div class="card-row">' + role.slice(0, 30) + '</div>' +
3627
+ '<div class="card-row">Backend: <span>' + inst.backend + '</span> · Tools: <span>' + inst.tool_set + '</span></div>' +
3628
+ '<div class="card-row">IPC: <span>' + ipc + '</span> · Cost: <span>' + costStr + '</span>' + rl + '</div>' +
3629
+ '<div class="card-row">Last: <span>' + lastStr + '</span></div>' +
3630
+ taskLine +
3631
+ '</div>';
3632
+ });
3633
+
3634
+ const newHtml = cards.join('');
3635
+ if (newHtml !== prevBoard) {
3636
+ board.innerHTML = newHtml;
3637
+ // Flash changed cards
3638
+ board.querySelectorAll('.card').forEach(c => {
3639
+ c.classList.add('flash');
3640
+ setTimeout(() => c.classList.remove('flash'), 1000);
3641
+ });
3642
+ prevBoard = newHtml;
3643
+ }
3644
+ }
3645
+
3646
+ // Auto-refresh board every 10s
3647
+ setInterval(loadBoard, 10000);
3648
+
3649
+ // Auto-load on page open
3650
+ loadBoard();
3651
+ load();
3652
+ </script>
3653
+ </body>
3654
+ </html>`;
3655
+ //# sourceMappingURL=fleet-manager.js.map