@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
package/dist/cli.js ADDED
@@ -0,0 +1,1833 @@
1
+ #!/usr/bin/env node
2
+ // Force IPv4 for all DNS lookups (fixes IPv6 timeout issues in corporate/WSL environments)
3
+ import dns from "node:dns";
4
+ dns.setDefaultResultOrder("ipv4first");
5
+ import { Command } from "commander";
6
+ import { join, dirname } from "node:path";
7
+ import { SchedulerDb } from "./scheduler/db.js";
8
+ import { Cron } from "croner";
9
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, rmSync, statSync, } from "node:fs";
10
+ import { homedir, totalmem, freemem } from "node:os";
11
+ import { fileURLToPath } from "node:url";
12
+ import { spawn, execSync, execFileSync } from "node:child_process";
13
+ import { getAgendHome, getTmuxSocketName } from "./paths.js";
14
+ /** Prefix tmux args with -L when socket isolation is active. */
15
+ function tmuxArgs(args) {
16
+ const socket = getTmuxSocketName();
17
+ return socket ? ["-L", socket, ...args] : args;
18
+ }
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = dirname(__filename);
21
+ const DATA_DIR = getAgendHome();
22
+ const FLEET_CONFIG_PATH = join(DATA_DIR, "fleet.yaml");
23
+ const program = new Command();
24
+ // Read version from package.json at build time
25
+ const pkgVersion = (() => {
26
+ try {
27
+ const pkgPath = join(__dirname, "..", "package.json");
28
+ return JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "0.0.0";
29
+ }
30
+ catch {
31
+ return "0.0.0";
32
+ }
33
+ })();
34
+ program
35
+ .name("agend")
36
+ .description("AgEnD — AI Engineering Daemon")
37
+ .version(pkgVersion);
38
+ function signalFleetReload() {
39
+ const pidPath = join(DATA_DIR, "fleet.pid");
40
+ try {
41
+ const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
42
+ process.kill(pid, "SIGHUP");
43
+ console.log("Fleet manager notified to reload config.");
44
+ }
45
+ catch {
46
+ console.log("Fleet manager not running. Config will be loaded on next start.");
47
+ }
48
+ }
49
+ // === Fleet commands ===
50
+ const fleet = program.command("fleet").description("Fleet management");
51
+ fleet
52
+ .command("start")
53
+ .description("Start fleet or specific instance")
54
+ .argument("[instance]", "Specific instance to start")
55
+ .action(async (instance) => {
56
+ if (instance) {
57
+ // If fleet daemon is already running, delegate via HTTP API
58
+ const { loadFleetConfig } = await import("./config.js");
59
+ const fleet = loadFleetConfig(FLEET_CONFIG_PATH);
60
+ const port = fleet.health_port ?? 19280;
61
+ try {
62
+ const healthResp = await fetch(`http://127.0.0.1:${port}/health`);
63
+ if (healthResp.ok) {
64
+ try {
65
+ const resp = await fetch(`http://127.0.0.1:${port}/api/instance/${encodeURIComponent(instance)}/start`, { method: "POST" });
66
+ const body = await resp.json();
67
+ if (resp.ok) {
68
+ console.log(`Instance "${instance}" started via running fleet daemon`);
69
+ }
70
+ else {
71
+ console.error(`Start failed: ${body.error ?? resp.statusText}`);
72
+ process.exit(1);
73
+ }
74
+ }
75
+ catch (err) {
76
+ console.error(`Failed to start instance via fleet API: ${err.message}`);
77
+ process.exit(1);
78
+ }
79
+ return;
80
+ }
81
+ }
82
+ catch { /* fleet not running, fall through */ }
83
+ }
84
+ const { FleetManager } = await import("./fleet-manager.js");
85
+ const fm = new FleetManager(DATA_DIR);
86
+ if (instance) {
87
+ const config = fm.loadConfig(FLEET_CONFIG_PATH);
88
+ const inst = config.instances[instance];
89
+ if (!inst) {
90
+ console.error(`Instance "${instance}" not found in fleet config`);
91
+ process.exit(1);
92
+ }
93
+ const topicMode = config.channel?.mode === "topic";
94
+ await fm.startInstance(instance, inst, topicMode);
95
+ }
96
+ else {
97
+ if (process.env.AGEND_HOME) {
98
+ console.log(` Using AGEND_HOME: ${process.env.AGEND_HOME}`);
99
+ }
100
+ await fm.startAll(FLEET_CONFIG_PATH);
101
+ }
102
+ console.log("Fleet started");
103
+ // Keep process alive + clean shutdown on Ctrl+C
104
+ const shutdown = async () => {
105
+ console.log("\nStopping fleet...");
106
+ await fm.stopAll();
107
+ process.exit(0);
108
+ };
109
+ process.on("SIGINT", shutdown);
110
+ process.on("SIGTERM", shutdown);
111
+ process.on("uncaughtException", async (err) => {
112
+ console.error("Uncaught exception:", err);
113
+ await fm.stopAll().catch(() => { });
114
+ process.exit(1);
115
+ });
116
+ process.on("unhandledRejection", async (err) => {
117
+ const msg = err instanceof Error ? err.message : String(err);
118
+ // 409 = another bot poller exists — adapter handles retry, don't crash
119
+ if (msg.includes("409") && msg.includes("getUpdates")) {
120
+ console.error("Bot polling conflict (409) — retrying...");
121
+ return;
122
+ }
123
+ console.error("Unhandled rejection:", err);
124
+ await fm.stopAll().catch(() => { });
125
+ process.exit(1);
126
+ });
127
+ });
128
+ fleet
129
+ .command("stop")
130
+ .description("Stop fleet or specific instance")
131
+ .argument("[instance]", "Specific instance to stop")
132
+ .action(async (instance) => {
133
+ if (instance) {
134
+ const { FleetManager } = await import("./fleet-manager.js");
135
+ const fm = new FleetManager(DATA_DIR);
136
+ await fm.stopInstance(instance);
137
+ console.log("Stopped");
138
+ }
139
+ else {
140
+ const pidPath = join(DATA_DIR, "fleet.pid");
141
+ if (!existsSync(pidPath)) {
142
+ console.error("Fleet is not running (no PID file found)");
143
+ process.exit(1);
144
+ }
145
+ const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
146
+ try {
147
+ process.kill(pid, "SIGTERM");
148
+ }
149
+ catch {
150
+ console.error("Failed to send SIGTERM (process may have already exited)");
151
+ try {
152
+ unlinkSync(pidPath);
153
+ }
154
+ catch { }
155
+ process.exit(1);
156
+ }
157
+ // Wait for process exit (up to 10 seconds)
158
+ const deadline = Date.now() + 10_000;
159
+ while (Date.now() < deadline) {
160
+ try {
161
+ process.kill(pid, 0);
162
+ await new Promise(r => setTimeout(r, 200));
163
+ }
164
+ catch {
165
+ // Process exited
166
+ console.log("Fleet stopped");
167
+ return;
168
+ }
169
+ }
170
+ console.warn("Warning: fleet process still running after 10s");
171
+ }
172
+ });
173
+ fleet
174
+ .command("restart")
175
+ .description("Graceful restart: wait for instances to idle, then restart")
176
+ .argument("[instance]", "Specific instance to restart (immediate, no idle wait)")
177
+ .option("--reload", "Full process restart to load new code")
178
+ .action(async (instance, opts) => {
179
+ if (instance && opts?.reload) {
180
+ console.error("--reload restarts the entire fleet process. Cannot combine with instance name.");
181
+ process.exit(1);
182
+ }
183
+ if (instance) {
184
+ // Single instance restart via fleet's HTTP API
185
+ const { loadFleetConfig } = await import("./config.js");
186
+ const fleet = loadFleetConfig(FLEET_CONFIG_PATH);
187
+ const port = fleet.health_port ?? 19280;
188
+ try {
189
+ const resp = await fetch(`http://127.0.0.1:${port}/restart/${encodeURIComponent(instance)}`, { method: "POST" });
190
+ const body = await resp.json();
191
+ if (resp.ok) {
192
+ console.log(`Instance "${instance}" restarted (immediate)`);
193
+ }
194
+ else {
195
+ console.error(`Restart failed: ${body.error ?? resp.statusText}`);
196
+ process.exit(1);
197
+ }
198
+ }
199
+ catch (err) {
200
+ console.error(`Cannot connect to fleet (port ${port}). Is the fleet running?`);
201
+ process.exit(1);
202
+ }
203
+ return;
204
+ }
205
+ const pidPath = join(DATA_DIR, "fleet.pid");
206
+ if (!existsSync(pidPath)) {
207
+ console.error("Fleet is not running (no PID file found)");
208
+ process.exit(1);
209
+ }
210
+ const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
211
+ if (!Number.isInteger(pid) || pid <= 0) {
212
+ console.error(`Fleet PID file at ${pidPath} is corrupted`);
213
+ process.exit(1);
214
+ }
215
+ if (opts?.reload) {
216
+ // Check if managed by launchd — if so, just signal and let launchd restart
217
+ let managedByLaunchd = false;
218
+ try {
219
+ const ppid = parseInt(execFileSync("ps", ["-o", "ppid=", "-p", String(pid)]).toString().trim(), 10);
220
+ managedByLaunchd = ppid === 1;
221
+ }
222
+ catch { /* ignore */ }
223
+ try {
224
+ process.kill(pid, "SIGUSR1");
225
+ }
226
+ catch {
227
+ console.error("Failed to send reload signal (process may have exited)");
228
+ process.exit(1);
229
+ }
230
+ if (managedByLaunchd) {
231
+ console.log("Fleet is managed by launchd — sent reload signal.");
232
+ console.log("launchd will automatically restart with new code.");
233
+ // Wait briefly for old process to exit, then confirm new one started
234
+ const deadline = Date.now() + 6 * 60 * 1000;
235
+ while (Date.now() < deadline) {
236
+ try {
237
+ process.kill(pid, 0);
238
+ await new Promise(r => setTimeout(r, 500));
239
+ }
240
+ catch {
241
+ break;
242
+ }
243
+ }
244
+ // Wait for launchd to start new process
245
+ await new Promise(r => setTimeout(r, 2000));
246
+ if (existsSync(pidPath)) {
247
+ const newPid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
248
+ if (newPid !== pid) {
249
+ console.log(`New fleet process started (PID ${newPid})`);
250
+ }
251
+ }
252
+ return;
253
+ }
254
+ console.log("Full restart signal sent — waiting for fleet to stop...");
255
+ // Wait for old process to exit (up to 6 minutes: 5 min idle wait + buffer)
256
+ const deadline = Date.now() + 6 * 60 * 1000;
257
+ while (Date.now() < deadline) {
258
+ try {
259
+ process.kill(pid, 0); // check if alive
260
+ await new Promise(r => setTimeout(r, 500));
261
+ }
262
+ catch {
263
+ break; // process exited
264
+ }
265
+ }
266
+ // Verify it actually exited
267
+ try {
268
+ process.kill(pid, 0);
269
+ console.error("Old fleet process still running after timeout — aborting");
270
+ process.exit(1);
271
+ }
272
+ catch {
273
+ // Good, it exited
274
+ }
275
+ console.log("Old fleet stopped. Starting with new code...");
276
+ // Start new fleet in this process (new Node.js process = new code loaded)
277
+ const { FleetManager } = await import("./fleet-manager.js");
278
+ const fm = new FleetManager(DATA_DIR);
279
+ await fm.startAll(FLEET_CONFIG_PATH);
280
+ console.log("Fleet restarted with new code");
281
+ // Keep process alive (same as fleet start)
282
+ const shutdown = async () => {
283
+ console.log("\nStopping fleet...");
284
+ await fm.stopAll();
285
+ process.exit(0);
286
+ };
287
+ process.on("SIGINT", shutdown);
288
+ process.on("SIGTERM", shutdown);
289
+ process.on("uncaughtException", async (err) => {
290
+ console.error("Uncaught exception:", err);
291
+ await fm.stopAll().catch(() => { });
292
+ process.exit(1);
293
+ });
294
+ process.on("unhandledRejection", async (err) => {
295
+ const msg = err instanceof Error ? err.message : String(err);
296
+ if (msg.includes("409") && msg.includes("getUpdates")) {
297
+ console.error("Bot polling conflict (409) — retrying...");
298
+ return;
299
+ }
300
+ console.error("Unhandled rejection:", err);
301
+ await fm.stopAll().catch(() => { });
302
+ process.exit(1);
303
+ });
304
+ }
305
+ else {
306
+ // Instance-only restart (existing behavior)
307
+ try {
308
+ process.kill(pid, "SIGUSR2");
309
+ console.log("Graceful restart signal sent — fleet will restart when all instances are idle");
310
+ }
311
+ catch {
312
+ console.error("Failed to send restart signal (process may have exited)");
313
+ process.exit(1);
314
+ }
315
+ }
316
+ });
317
+ fleet
318
+ .command("status")
319
+ .description("Show fleet status (alias for `agend ls`)")
320
+ .option("--json", "Output as JSON")
321
+ .action(async (opts) => {
322
+ // Delegate to the `ls` command implementation
323
+ await lsAction(opts);
324
+ });
325
+ fleet
326
+ .command("logs")
327
+ .description("Alias for `agend logs`")
328
+ .action(() => {
329
+ console.log("Use `agend logs` instead. Run `agend logs --help` for options.");
330
+ });
331
+ fleet
332
+ .command("history")
333
+ .description("Show fleet event history")
334
+ .option("--instance <name>", "Filter by instance name")
335
+ .option("--type <type>", "Filter by event type")
336
+ .option("--since <date>", "Filter events since date (ISO format)")
337
+ .option("--limit <n>", "Number of events to show", "50")
338
+ .option("--json", "Output as JSON")
339
+ .action(async (opts) => {
340
+ const { EventLog } = await import("./event-log.js");
341
+ const evLog = new EventLog(join(DATA_DIR, "events.db"));
342
+ try {
343
+ const rows = evLog.query({
344
+ instance: opts.instance,
345
+ type: opts.type,
346
+ since: opts.since,
347
+ limit: parseInt(opts.limit, 10),
348
+ });
349
+ if (opts.json) {
350
+ console.log(JSON.stringify(rows, null, 2));
351
+ return;
352
+ }
353
+ if (rows.length === 0) {
354
+ console.log("No events found.");
355
+ return;
356
+ }
357
+ const instWidth = Math.max(20, ...rows.map(r => r.instance_name.length + 2));
358
+ console.log("Time".padEnd(22) + "Instance".padEnd(instWidth) + "Type".padEnd(25) + "Payload");
359
+ console.log("\u2500".repeat(22 + instWidth + 25 + 23));
360
+ for (const r of rows) {
361
+ const payloadStr = r.payload != null ? JSON.stringify(r.payload) : "";
362
+ console.log(r.created_at.padEnd(22) +
363
+ r.instance_name.padEnd(instWidth) +
364
+ r.event_type.padEnd(25) +
365
+ payloadStr);
366
+ }
367
+ }
368
+ finally {
369
+ evLog.close();
370
+ }
371
+ });
372
+ fleet
373
+ .command("activity")
374
+ .description("Show fleet activity log — who talked to whom, tool calls, task updates")
375
+ .option("--since <duration>", "Time window, e.g. '2h', '30m', '1d'", "2h")
376
+ .option("--limit <n>", "Max entries", "200")
377
+ .option("--format <fmt>", "Output format: text or mermaid", "text")
378
+ .action(async (opts) => {
379
+ const { EventLog } = await import("./event-log.js");
380
+ const evLog = new EventLog(join(DATA_DIR, "events.db"));
381
+ try {
382
+ // Parse --since duration to ISO timestamp
383
+ const match = opts.since.match(/^(\d+)(m|h|d)$/);
384
+ let sinceIso;
385
+ if (match) {
386
+ const val = parseInt(match[1], 10);
387
+ const unit = match[2] === "d" ? 86400000 : match[2] === "h" ? 3600000 : 60000;
388
+ sinceIso = new Date(Date.now() - val * unit).toISOString();
389
+ }
390
+ const rows = evLog.listActivity({ since: sinceIso, limit: parseInt(opts.limit, 10) });
391
+ if (rows.length === 0) {
392
+ console.log("No activity found.");
393
+ return;
394
+ }
395
+ if (opts.format === "mermaid") {
396
+ console.log(generateMermaid(rows));
397
+ }
398
+ else {
399
+ for (const r of rows) {
400
+ const time = r.timestamp.replace("T", " ").slice(0, 19);
401
+ const arrow = r.receiver ? `${r.sender} → ${r.receiver}` : r.sender;
402
+ const icon = r.event === "message" ? "💬" : r.event === "tool_call" ? "🔧" : "📋";
403
+ console.log(`${time} ${icon} ${arrow}: ${r.summary}`);
404
+ }
405
+ }
406
+ }
407
+ finally {
408
+ evLog.close();
409
+ }
410
+ });
411
+ function generateMermaid(rows) {
412
+ // Collect unique participants
413
+ const participants = new Set();
414
+ for (const r of rows) {
415
+ participants.add(r.sender);
416
+ if (r.receiver)
417
+ participants.add(r.receiver);
418
+ }
419
+ const lines = ["sequenceDiagram"];
420
+ // Declare participants (shorter aliases)
421
+ const aliases = new Map();
422
+ let idx = 0;
423
+ for (const p of participants) {
424
+ const alias = p.length > 10 ? String.fromCharCode(65 + idx++) : p;
425
+ aliases.set(p, alias);
426
+ lines.push(` participant ${alias} as ${p}`);
427
+ }
428
+ // Generate events
429
+ for (const r of rows) {
430
+ const s = aliases.get(r.sender) ?? r.sender;
431
+ const summary = r.summary.replace(/"/g, "'").slice(0, 80);
432
+ if (r.event === "tool_call") {
433
+ lines.push(` Note over ${s}: 🔧 ${summary}`);
434
+ }
435
+ else if (r.receiver) {
436
+ const recv = aliases.get(r.receiver) ?? r.receiver;
437
+ lines.push(` ${s}->>${recv}: ${summary}`);
438
+ }
439
+ else {
440
+ lines.push(` Note over ${s}: ${summary}`);
441
+ }
442
+ }
443
+ return lines.join("\n");
444
+ }
445
+ fleet
446
+ .command("cleanup")
447
+ .description("Remove orphaned instance directories not in fleet.yaml")
448
+ .option("--dry-run", "List orphans without deleting")
449
+ .action(async (opts) => {
450
+ const { FleetManager } = await import("./fleet-manager.js");
451
+ const fm = new FleetManager(DATA_DIR);
452
+ const config = fm.loadConfig(FLEET_CONFIG_PATH);
453
+ const configuredNames = new Set(Object.keys(config.instances));
454
+ const instancesDir = join(DATA_DIR, "instances");
455
+ if (!existsSync(instancesDir)) {
456
+ console.log("No instances directory.");
457
+ return;
458
+ }
459
+ const dirs = readdirSync(instancesDir).filter(d => !configuredNames.has(d));
460
+ if (dirs.length === 0) {
461
+ console.log("No orphaned directories.");
462
+ return;
463
+ }
464
+ console.log(`Found ${dirs.length} orphaned instance directories:`);
465
+ for (const d of dirs)
466
+ console.log(` ${d}`);
467
+ if (opts.dryRun)
468
+ return;
469
+ for (const d of dirs) {
470
+ rmSync(join(instancesDir, d), { recursive: true, force: true });
471
+ console.log(` Removed: ${d}`);
472
+ }
473
+ console.log(`Cleaned up ${dirs.length} directories.`);
474
+ // Clean stale files from active instances
475
+ const staleFiles = ["memory.db", "sandbox-bash"];
476
+ let staleCount = 0;
477
+ for (const name of configuredNames) {
478
+ const instDir = join(instancesDir, name);
479
+ for (const f of staleFiles) {
480
+ const p = join(instDir, f);
481
+ if (existsSync(p)) {
482
+ if (!opts.dryRun)
483
+ rmSync(p, { force: true });
484
+ staleCount++;
485
+ }
486
+ }
487
+ }
488
+ if (staleCount > 0)
489
+ console.log(`Removed ${staleCount} stale files (memory.db, sandbox-bash).`);
490
+ });
491
+ // === Backend commands ===
492
+ const backend = program.command("backend").description("Backend diagnostics");
493
+ backend
494
+ .command("doctor")
495
+ .description("Check backend prerequisites and configuration")
496
+ .argument("[backend]", "Backend to check (claude-code, codex, gemini-cli, opencode, kiro-cli)", "claude-code")
497
+ .action(async (backendName) => {
498
+ const backends = {
499
+ "claude-code": { binary: "claude", label: "Claude Code", install: "npm i -g @anthropic-ai/claude-code", auth: "claude (OAuth) or ANTHROPIC_API_KEY" },
500
+ "codex": { binary: "codex", label: "OpenAI Codex", install: "npm i -g @openai/codex", auth: "OPENAI_API_KEY" },
501
+ "gemini-cli": { binary: "gemini", label: "Gemini CLI", install: "npm i -g @google/gemini-cli", auth: "gemini (Google OAuth)" },
502
+ "opencode": { binary: "opencode", label: "OpenCode", install: "go install github.com/opencode-ai/opencode@latest", auth: "Configure provider API key" },
503
+ "kiro-cli": { binary: "kiro-cli", label: "Kiro CLI", install: "brew install --cask kiro-cli", auth: "kiro-cli login (AWS Builder ID)" },
504
+ };
505
+ const info = backends[backendName];
506
+ if (!info) {
507
+ console.error(`Unknown backend: ${backendName}. Available: ${Object.keys(backends).join(", ")}`);
508
+ process.exit(1);
509
+ }
510
+ let issues = 0;
511
+ const ok = (msg) => console.log(` \x1b[32m✓\x1b[0m ${msg}`);
512
+ const fail = (msg) => { issues++; console.log(` \x1b[31m✗\x1b[0m ${msg}`); };
513
+ console.log(`\n \x1b[1magend backend doctor ${backendName}\x1b[0m\n`);
514
+ // Binary
515
+ try {
516
+ const { execSync } = await import("node:child_process");
517
+ const ver = execSync(`${info.binary} --version`, { stdio: "pipe" }).toString().trim();
518
+ const which = execSync(`which ${info.binary}`, { stdio: "pipe" }).toString().trim();
519
+ ok(`${info.binary.padEnd(20)} ${which} (${ver})`);
520
+ }
521
+ catch {
522
+ fail(`${info.binary.padEnd(20)} not found — Install: ${info.install}`);
523
+ }
524
+ // tmux
525
+ try {
526
+ const { execSync } = await import("node:child_process");
527
+ const ver = execSync("tmux -V", { stdio: "pipe" }).toString().trim();
528
+ ok(`tmux${" ".repeat(16)} ${ver}`);
529
+ }
530
+ catch {
531
+ fail(`tmux${" ".repeat(16)} not found — brew install tmux / apt install tmux`);
532
+ }
533
+ // TERM
534
+ if (process.env.TERM) {
535
+ ok(`TERM${" ".repeat(16)} ${process.env.TERM}`);
536
+ }
537
+ else {
538
+ fail(`TERM${" ".repeat(16)} not set — may cause TUI issues in daemon mode`);
539
+ }
540
+ // Gemini trust check
541
+ if (backendName === "gemini-cli") {
542
+ try {
543
+ const trustFile = join(homedir(), ".gemini", "trustedFolders.json");
544
+ if (existsSync(trustFile)) {
545
+ const trusted = JSON.parse(readFileSync(trustFile, "utf-8"));
546
+ const count = typeof trusted === "object" ? Object.keys(trusted).length : 0;
547
+ ok(`Trust config${" ".repeat(8)} ${count} folder(s) trusted`);
548
+ }
549
+ else {
550
+ fail(`Trust config${" ".repeat(8)} ~/.gemini/trustedFolders.json not found`);
551
+ }
552
+ }
553
+ catch {
554
+ fail(`Trust config${" ".repeat(8)} Could not read trust config`);
555
+ }
556
+ }
557
+ // Claude Code OAuth check
558
+ if (backendName === "claude-code") {
559
+ try {
560
+ const claudeJson = join(homedir(), ".claude.json");
561
+ if (existsSync(claudeJson)) {
562
+ const cfg = JSON.parse(readFileSync(claudeJson, "utf-8"));
563
+ if (cfg.oauthAccount?.accountUuid) {
564
+ ok(`OAuth${" ".repeat(15)} Signed in`);
565
+ }
566
+ else if (process.env.ANTHROPIC_API_KEY) {
567
+ ok(`API Key${" ".repeat(13)} ANTHROPIC_API_KEY set`);
568
+ }
569
+ else {
570
+ fail(`Auth${" ".repeat(16)} No OAuth session or ANTHROPIC_API_KEY`);
571
+ }
572
+ }
573
+ else if (process.env.ANTHROPIC_API_KEY) {
574
+ ok(`API Key${" ".repeat(13)} ANTHROPIC_API_KEY set`);
575
+ }
576
+ else {
577
+ fail(`Auth${" ".repeat(16)} No ~/.claude.json or ANTHROPIC_API_KEY`);
578
+ }
579
+ }
580
+ catch {
581
+ fail(`Auth${" ".repeat(16)} Could not check auth status`);
582
+ }
583
+ }
584
+ console.log();
585
+ if (issues === 0) {
586
+ console.log(` \x1b[32m✓ All checks passed\x1b[0m`);
587
+ }
588
+ else {
589
+ console.log(` \x1b[31m${issues} issue(s) found\x1b[0m`);
590
+ }
591
+ console.log();
592
+ });
593
+ backend
594
+ .command("trust")
595
+ .description("Pre-trust working directories for a backend (prevents trust dialogs)")
596
+ .argument("<backend>", "Backend (gemini-cli)")
597
+ .argument("[directories...]", "Directories to trust (defaults to all fleet instance dirs)")
598
+ .action(async (backendName, directories) => {
599
+ if (backendName !== "gemini-cli") {
600
+ console.log(`${backendName} uses CLI flags to skip trust dialogs — no manual trust needed.`);
601
+ return;
602
+ }
603
+ const { GeminiCliBackend } = await import("./backend/gemini-cli.js");
604
+ const gemini = new GeminiCliBackend(DATA_DIR);
605
+ let dirs = directories;
606
+ if (dirs.length === 0) {
607
+ // Trust all fleet instance working directories
608
+ try {
609
+ const { loadFleetConfig } = await import("./config.js");
610
+ const config = loadFleetConfig(FLEET_CONFIG_PATH);
611
+ dirs = Object.values(config.instances).map(i => i.working_directory);
612
+ }
613
+ catch {
614
+ console.error("No directories specified and no fleet config found.");
615
+ process.exit(1);
616
+ }
617
+ }
618
+ for (const dir of dirs) {
619
+ const expanded = dir.replace(/^~/, homedir());
620
+ gemini.preTrust(expanded);
621
+ console.log(` \x1b[32m✓\x1b[0m Trusted: ${expanded}`);
622
+ }
623
+ console.log(`\n ${dirs.length} directory(s) trusted for Gemini CLI.`);
624
+ });
625
+ // === Topic commands ===
626
+ const topic = program.command("topic").description("Topic binding management");
627
+ topic
628
+ .command("list")
629
+ .description("List topic bindings")
630
+ .action(async () => {
631
+ const { loadFleetConfig } = await import("./config.js");
632
+ const config = loadFleetConfig(FLEET_CONFIG_PATH);
633
+ let found = false;
634
+ for (const [name, inst] of Object.entries(config.instances)) {
635
+ if (inst.topic_id != null) {
636
+ console.log(`${name} \u2192 topic #${inst.topic_id}`);
637
+ found = true;
638
+ }
639
+ }
640
+ if (!found) {
641
+ console.log("No topic bindings configured");
642
+ }
643
+ });
644
+ topic
645
+ .command("bind")
646
+ .description("Bind an instance to a topic")
647
+ .argument("<instance>", "Instance name")
648
+ .argument("<topic-id>", "Topic ID")
649
+ .action(async (instance, topicId) => {
650
+ const { loadFleetConfig } = await import("./config.js");
651
+ const yaml = await import("js-yaml");
652
+ const config = loadFleetConfig(FLEET_CONFIG_PATH);
653
+ if (!config.instances[instance]) {
654
+ console.error(`Instance "${instance}" not found in fleet config`);
655
+ process.exit(1);
656
+ }
657
+ config.instances[instance].topic_id = topicId;
658
+ writeFileSync(FLEET_CONFIG_PATH, yaml.dump(config));
659
+ console.log(`Bound ${instance} \u2192 topic #${topicId}`);
660
+ });
661
+ topic
662
+ .command("unbind")
663
+ .description("Unbind an instance from its topic")
664
+ .argument("<instance>", "Instance name")
665
+ .action(async (instance) => {
666
+ const { loadFleetConfig } = await import("./config.js");
667
+ const yaml = await import("js-yaml");
668
+ const config = loadFleetConfig(FLEET_CONFIG_PATH);
669
+ if (!config.instances[instance]) {
670
+ console.error(`Instance "${instance}" not found in fleet config`);
671
+ process.exit(1);
672
+ }
673
+ delete config.instances[instance].topic_id;
674
+ writeFileSync(FLEET_CONFIG_PATH, yaml.dump(config));
675
+ console.log(`Unbound ${instance} from topic`);
676
+ });
677
+ // === Access commands ===
678
+ const access = program
679
+ .command("access")
680
+ .description("Access control for instances");
681
+ async function resolveAccessPath(instance) {
682
+ const { loadFleetConfig } = await import("./config.js");
683
+ const { resolveAccessPathFromConfig } = await import("./access-path.js");
684
+ const config = loadFleetConfig(FLEET_CONFIG_PATH);
685
+ const inst = config.instances[instance];
686
+ return resolveAccessPathFromConfig(DATA_DIR, instance, config.channel);
687
+ }
688
+ access
689
+ .command("lock")
690
+ .description("Lock instance access")
691
+ .argument("<instance>", "Instance name")
692
+ .action(async (instance) => {
693
+ const { AccessManager } = await import("./channel/access-manager.js");
694
+ const instanceDir = join(DATA_DIR, "instances", instance);
695
+ if (!existsSync(instanceDir)) {
696
+ console.error(`Instance "${instance}" not found`);
697
+ process.exit(1);
698
+ }
699
+ const statePath = await resolveAccessPath(instance);
700
+ const am = new AccessManager({ mode: "locked", allowed_users: [], max_pending_codes: 5, code_expiry_minutes: 10 }, statePath);
701
+ am.setMode("locked");
702
+ console.log(`${instance}: locked`);
703
+ });
704
+ access
705
+ .command("unlock")
706
+ .description("Unlock instance access")
707
+ .argument("<instance>", "Instance name")
708
+ .action(async (instance) => {
709
+ const { AccessManager } = await import("./channel/access-manager.js");
710
+ const instanceDir = join(DATA_DIR, "instances", instance);
711
+ if (!existsSync(instanceDir)) {
712
+ console.error(`Instance "${instance}" not found`);
713
+ process.exit(1);
714
+ }
715
+ const statePath = await resolveAccessPath(instance);
716
+ const am = new AccessManager({ mode: "pairing", allowed_users: [], max_pending_codes: 5, code_expiry_minutes: 10 }, statePath);
717
+ am.setMode("pairing");
718
+ console.log(`${instance}: unlocked`);
719
+ });
720
+ access
721
+ .command("list")
722
+ .description("List allowed users for an instance")
723
+ .argument("<instance>", "Instance name")
724
+ .action(async (instance) => {
725
+ const { AccessManager } = await import("./channel/access-manager.js");
726
+ const instanceDir = join(DATA_DIR, "instances", instance);
727
+ if (!existsSync(instanceDir)) {
728
+ console.error(`Instance "${instance}" not found`);
729
+ process.exit(1);
730
+ }
731
+ const statePath = await resolveAccessPath(instance);
732
+ const am = new AccessManager({ mode: "pairing", allowed_users: [], max_pending_codes: 5, code_expiry_minutes: 10 }, statePath);
733
+ const users = am.getAllowedUsers();
734
+ if (users.length === 0) {
735
+ console.log(`${instance}: no allowed users`);
736
+ }
737
+ else {
738
+ console.log(`${instance} allowed users:`);
739
+ for (const uid of users) {
740
+ console.log(` - ${uid}`);
741
+ }
742
+ }
743
+ });
744
+ access
745
+ .command("remove")
746
+ .description("Remove a user from allowed list")
747
+ .argument("<instance>", "Instance name")
748
+ .argument("<user-id>", "User ID to remove")
749
+ .action(async (instance, userId) => {
750
+ const { AccessManager } = await import("./channel/access-manager.js");
751
+ const instanceDir = join(DATA_DIR, "instances", instance);
752
+ if (!existsSync(instanceDir)) {
753
+ console.error(`Instance "${instance}" not found`);
754
+ process.exit(1);
755
+ }
756
+ const statePath = await resolveAccessPath(instance);
757
+ const am = new AccessManager({ mode: "pairing", allowed_users: [], max_pending_codes: 5, code_expiry_minutes: 10 }, statePath);
758
+ am.removeUser(userId);
759
+ console.log(`${instance}: removed user ${userId}`);
760
+ });
761
+ access
762
+ .command("pair")
763
+ .description("Generate a pairing code for a user")
764
+ .argument("<instance>", "Instance name")
765
+ .argument("<user-id>", "Telegram user ID requesting pairing")
766
+ .action(async (instance, userId) => {
767
+ const { AccessManager } = await import("./channel/access-manager.js");
768
+ const instanceDir = join(DATA_DIR, "instances", instance);
769
+ if (!existsSync(instanceDir)) {
770
+ console.error(`Instance "${instance}" not found`);
771
+ process.exit(1);
772
+ }
773
+ const statePath = await resolveAccessPath(instance);
774
+ const am = new AccessManager({ mode: "pairing", allowed_users: [], max_pending_codes: 5, code_expiry_minutes: 10 }, statePath);
775
+ const code = am.generateCode(userId);
776
+ console.log(`${instance}: pairing code = ${code}`);
777
+ console.log("Share this code with the user. It expires in 10 minutes.");
778
+ });
779
+ // === Update + Reload ===
780
+ program
781
+ .command("update")
782
+ .description("Update AgEnD to latest version and restart service")
783
+ .option("--skip-install", "Skip npm install, only restart service")
784
+ .action(async (opts) => {
785
+ const { detectPlatform } = await import("./service-installer.js");
786
+ if (!opts.skipInstall) {
787
+ console.log(" Updating AgEnD...");
788
+ try {
789
+ execSync("npm install -g @suzuke/agend@latest", { stdio: "inherit" });
790
+ }
791
+ catch (err) {
792
+ console.error(" Failed to update. Try: npm install -g @suzuke/agend@latest");
793
+ process.exit(1);
794
+ }
795
+ }
796
+ const plat = detectPlatform();
797
+ const label = "com.agend.fleet";
798
+ if (plat === "macos") {
799
+ const plistPath = join(homedir(), "Library/LaunchAgents", `${label}.plist`);
800
+ if (existsSync(plistPath)) {
801
+ const uid = process.getuid?.() ?? 501;
802
+ console.log(" Restarting launchd service...");
803
+ try {
804
+ execSync(`launchctl kickstart -k gui/${uid}/${label}`, { stdio: "inherit" });
805
+ console.log(" ✓ Service restarted with new version");
806
+ }
807
+ catch {
808
+ console.log(" Failed to restart service. Try: launchctl kickstart -k gui/" + uid + "/" + label);
809
+ }
810
+ return;
811
+ }
812
+ }
813
+ else {
814
+ try {
815
+ execSync(`systemctl --user restart ${label}`, { stdio: "inherit" });
816
+ console.log(" ✓ Service restarted with new version");
817
+ return;
818
+ }
819
+ catch { /* no systemd service */ }
820
+ }
821
+ // Fallback: signal running daemon
822
+ const pidPath = join(DATA_DIR, "fleet.pid");
823
+ if (existsSync(pidPath)) {
824
+ const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
825
+ try {
826
+ process.kill(pid, "SIGUSR1");
827
+ console.log(" ✓ Sent restart signal to running fleet (PID " + pid + ")");
828
+ }
829
+ catch {
830
+ console.log(" Fleet not running. Start with: agend fleet start");
831
+ }
832
+ }
833
+ else {
834
+ console.log(" No service or running fleet found. Start with: agend fleet start");
835
+ }
836
+ });
837
+ program
838
+ .command("reload")
839
+ .description("Hot-reload fleet config (re-read fleet.yaml, start new instances)")
840
+ .action(async () => {
841
+ const pidPath = join(DATA_DIR, "fleet.pid");
842
+ if (!existsSync(pidPath)) {
843
+ console.error("Fleet is not running. Start with: agend fleet start");
844
+ process.exit(1);
845
+ }
846
+ const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
847
+ try {
848
+ process.kill(pid, "SIGHUP");
849
+ console.log("✓ Sent SIGHUP to fleet (PID " + pid + ") — config will be reloaded");
850
+ }
851
+ catch {
852
+ console.error("Fleet process not found (PID " + pid + "). It may have crashed.");
853
+ process.exit(1);
854
+ }
855
+ });
856
+ // === Install/Uninstall ===
857
+ program
858
+ .command("install")
859
+ .description("Install as system service")
860
+ .option("--activate", "Stop manual fleet and load the service immediately")
861
+ .action(async (opts) => {
862
+ const { installService, activateService, detectPlatform } = await import("./service-installer.js");
863
+ const execPath = process.argv[1];
864
+ const svcPath = installService({
865
+ label: "com.agend.fleet",
866
+ execPath,
867
+ path: process.env.PATH,
868
+ workingDirectory: DATA_DIR,
869
+ logPath: join(DATA_DIR, "fleet.log"),
870
+ });
871
+ console.log(`Service installed at: ${svcPath}`);
872
+ if (opts.activate) {
873
+ const pidPath = join(DATA_DIR, "fleet.pid");
874
+ activateService(svcPath, pidPath);
875
+ console.log("Service activated.");
876
+ }
877
+ else {
878
+ const plat = detectPlatform();
879
+ if (plat === "macos") {
880
+ console.log(`Run: launchctl load ${svcPath}`);
881
+ }
882
+ else {
883
+ console.log(`Run: systemctl --user enable --now com.agend.fleet`);
884
+ }
885
+ }
886
+ });
887
+ program
888
+ .command("uninstall")
889
+ .description("Remove system service")
890
+ .action(async () => {
891
+ const { uninstallService } = await import("./service-installer.js");
892
+ const removed = uninstallService("com.agend.fleet");
893
+ if (removed) {
894
+ console.log("Service uninstalled");
895
+ }
896
+ else {
897
+ console.log("No service found to uninstall");
898
+ }
899
+ });
900
+ program
901
+ .command("stop")
902
+ .description("Stop the AgEnD service")
903
+ .action(async () => {
904
+ const { getServicePath, stopService } = await import("./service-installer.js");
905
+ if (!getServicePath()) {
906
+ // No service — try killing by PID
907
+ const pidPath = join(DATA_DIR, "fleet.pid");
908
+ if (existsSync(pidPath)) {
909
+ const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
910
+ try {
911
+ process.kill(pid, "SIGTERM");
912
+ console.log(`Stopped fleet (PID ${pid})`);
913
+ }
914
+ catch {
915
+ console.log("Fleet not running.");
916
+ }
917
+ }
918
+ else {
919
+ console.log("No service installed and no running fleet found.");
920
+ }
921
+ return;
922
+ }
923
+ if (stopService()) {
924
+ console.log("Service stopped.");
925
+ }
926
+ else {
927
+ console.log("Service is not running or already stopped.");
928
+ }
929
+ });
930
+ program
931
+ .command("start")
932
+ .description("Start the AgEnD service (must be installed first)")
933
+ .action(async () => {
934
+ const { getServicePath, startService } = await import("./service-installer.js");
935
+ if (!getServicePath()) {
936
+ console.log("No service installed. Run: agend install");
937
+ console.log("Or start manually: agend fleet start");
938
+ return;
939
+ }
940
+ if (startService()) {
941
+ console.log("Service started.");
942
+ }
943
+ else {
944
+ console.log("Failed to start service. Check: agend backend doctor");
945
+ }
946
+ });
947
+ program
948
+ .command("restart")
949
+ .description("Restart the AgEnD service")
950
+ .action(async () => {
951
+ const { getServicePath, stopService, startService } = await import("./service-installer.js");
952
+ if (!getServicePath()) {
953
+ console.log("No service installed. Run: agend install");
954
+ return;
955
+ }
956
+ const pidPath = join(DATA_DIR, "fleet.pid");
957
+ let oldPid = null;
958
+ try {
959
+ oldPid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
960
+ }
961
+ catch { }
962
+ stopService();
963
+ // Wait for old process to exit (up to 30s)
964
+ const deadline = Date.now() + 30_000;
965
+ while (Date.now() < deadline) {
966
+ // Check if process is still alive
967
+ if (oldPid) {
968
+ try {
969
+ process.kill(oldPid, 0);
970
+ }
971
+ catch {
972
+ break;
973
+ }
974
+ }
975
+ else if (!existsSync(pidPath)) {
976
+ break;
977
+ }
978
+ await new Promise(r => setTimeout(r, 500));
979
+ }
980
+ if (startService()) {
981
+ console.log("Service restarted.");
982
+ }
983
+ else {
984
+ console.log("Failed to restart service.");
985
+ }
986
+ });
987
+ program
988
+ .command("quickstart")
989
+ .description("Quick 3-step setup: detect backend, create bot, connect group")
990
+ .action(async () => {
991
+ const { runQuickstart } = await import("./quickstart.js");
992
+ await runQuickstart();
993
+ });
994
+ program
995
+ .command("init")
996
+ .description("Interactive setup wizard (advanced)")
997
+ .action(async () => {
998
+ const { runSetupWizard } = await import("./setup-wizard.js");
999
+ await runSetupWizard();
1000
+ });
1001
+ program
1002
+ .command("web")
1003
+ .description("Open the Web UI dashboard in your browser")
1004
+ .action(async () => {
1005
+ const tokenPath = join(DATA_DIR, "web.token");
1006
+ if (!existsSync(tokenPath)) {
1007
+ console.error("Web token not found. Is the fleet running?");
1008
+ process.exit(1);
1009
+ }
1010
+ const token = readFileSync(tokenPath, "utf-8").trim();
1011
+ const { loadFleetConfig } = await import("./config.js");
1012
+ const fleet = loadFleetConfig(FLEET_CONFIG_PATH);
1013
+ const port = fleet.health_port ?? 19280;
1014
+ const url = `http://localhost:${port}/ui?token=${encodeURIComponent(token)}`;
1015
+ console.log(`Opening ${url}`);
1016
+ // The token is sensitive: passing it on argv would expose it via `ps`,
1017
+ // and exec(`${cmd} "${url}"`) additionally goes through a shell. Instead,
1018
+ // write a 0600-mode HTML redirect into a per-user temp dir and open that
1019
+ // file path — the token only ever lives on disk under user-only perms.
1020
+ const { mkdtempSync } = await import("node:fs");
1021
+ const { tmpdir } = await import("node:os");
1022
+ const tmpDir = mkdtempSync(join(tmpdir(), "agend-web-"));
1023
+ const htmlPath = join(tmpDir, "open.html");
1024
+ const htmlUrl = url.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
1025
+ writeFileSync(htmlPath, `<!doctype html><meta http-equiv="refresh" content="0; url=${htmlUrl}">`, { mode: 0o600 });
1026
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
1027
+ const child = spawn(cmd, [htmlPath], { detached: true, stdio: "ignore" });
1028
+ child.unref();
1029
+ });
1030
+ // === Schedule commands ===
1031
+ const schedule = program.command("schedule").description("Manage scheduled tasks");
1032
+ schedule
1033
+ .command("list")
1034
+ .description("List all schedules")
1035
+ .option("--target <instance>", "Filter by target instance")
1036
+ .option("--json", "Output as JSON")
1037
+ .action((opts) => {
1038
+ const db = new SchedulerDb(join(DATA_DIR, "scheduler.db"));
1039
+ try {
1040
+ const schedules = db.list(opts.target);
1041
+ if (opts.json) {
1042
+ console.log(JSON.stringify(schedules, null, 2));
1043
+ return;
1044
+ }
1045
+ if (schedules.length === 0) {
1046
+ console.log("No schedules found.");
1047
+ return;
1048
+ }
1049
+ console.log("ID\t\t\t\t\tLabel\t\t\tCron\t\tTarget\tEnabled\tLast Status");
1050
+ for (const s of schedules) {
1051
+ console.log(`${s.id}\t${s.label ?? "-"}\t${s.cron}\t${s.target}\t${s.enabled ? "✅" : "❌"}\t${s.last_status ?? "-"}`);
1052
+ }
1053
+ }
1054
+ finally {
1055
+ db.close();
1056
+ }
1057
+ });
1058
+ schedule
1059
+ .command("add")
1060
+ .description("Add a new schedule")
1061
+ .requiredOption("--cron <expr>", "Cron expression")
1062
+ .requiredOption("--target <instance>", "Target instance")
1063
+ .requiredOption("--message <text>", "Message to send on trigger")
1064
+ .option("--label <text>", "Human-readable name")
1065
+ .option("--timezone <tz>", "IANA timezone", "Asia/Taipei")
1066
+ .action((opts) => {
1067
+ // Validate cron expression
1068
+ try {
1069
+ new Cron(opts.cron, { timezone: opts.timezone });
1070
+ }
1071
+ catch (err) {
1072
+ console.error(`Invalid cron expression: ${err.message}`);
1073
+ process.exit(1);
1074
+ }
1075
+ const db = new SchedulerDb(join(DATA_DIR, "scheduler.db"));
1076
+ try {
1077
+ const s = db.create({
1078
+ cron: opts.cron,
1079
+ message: opts.message,
1080
+ source: opts.target,
1081
+ target: opts.target,
1082
+ reply_chat_id: "",
1083
+ reply_thread_id: null,
1084
+ label: opts.label,
1085
+ timezone: opts.timezone,
1086
+ });
1087
+ console.log(`Created schedule ${s.id}`);
1088
+ signalFleetReload();
1089
+ }
1090
+ finally {
1091
+ db.close();
1092
+ }
1093
+ });
1094
+ schedule
1095
+ .command("update")
1096
+ .description("Update an existing schedule")
1097
+ .argument("<id>", "Schedule ID")
1098
+ .option("--cron <expr>", "New cron expression")
1099
+ .option("--message <text>", "New message")
1100
+ .option("--target <instance>", "New target instance")
1101
+ .option("--label <text>", "New label")
1102
+ .option("--timezone <tz>", "New timezone")
1103
+ .option("--enabled <bool>", "Enable/disable (true/false)")
1104
+ .action((id, opts) => {
1105
+ const db = new SchedulerDb(join(DATA_DIR, "scheduler.db"));
1106
+ try {
1107
+ const params = {};
1108
+ if (opts.cron)
1109
+ params.cron = opts.cron;
1110
+ if (opts.message)
1111
+ params.message = opts.message;
1112
+ if (opts.target)
1113
+ params.target = opts.target;
1114
+ if (opts.label)
1115
+ params.label = opts.label;
1116
+ if (opts.timezone)
1117
+ params.timezone = opts.timezone;
1118
+ if (opts.enabled !== undefined)
1119
+ params.enabled = opts.enabled === "true";
1120
+ db.update(id, params);
1121
+ console.log(`Updated schedule ${id}`);
1122
+ signalFleetReload();
1123
+ }
1124
+ finally {
1125
+ db.close();
1126
+ }
1127
+ });
1128
+ schedule
1129
+ .command("delete")
1130
+ .description("Delete a schedule")
1131
+ .argument("<id>", "Schedule ID")
1132
+ .action((id) => {
1133
+ const db = new SchedulerDb(join(DATA_DIR, "scheduler.db"));
1134
+ try {
1135
+ db.delete(id);
1136
+ console.log(`Deleted schedule ${id}`);
1137
+ signalFleetReload();
1138
+ }
1139
+ finally {
1140
+ db.close();
1141
+ }
1142
+ });
1143
+ schedule
1144
+ .command("enable")
1145
+ .description("Enable a schedule")
1146
+ .argument("<id>", "Schedule ID")
1147
+ .action((id) => {
1148
+ const db = new SchedulerDb(join(DATA_DIR, "scheduler.db"));
1149
+ try {
1150
+ db.update(id, { enabled: true });
1151
+ console.log(`Enabled schedule ${id}`);
1152
+ signalFleetReload();
1153
+ }
1154
+ finally {
1155
+ db.close();
1156
+ }
1157
+ });
1158
+ schedule
1159
+ .command("disable")
1160
+ .description("Disable a schedule")
1161
+ .argument("<id>", "Schedule ID")
1162
+ .action((id) => {
1163
+ const db = new SchedulerDb(join(DATA_DIR, "scheduler.db"));
1164
+ try {
1165
+ db.update(id, { enabled: false });
1166
+ console.log(`Disabled schedule ${id}`);
1167
+ signalFleetReload();
1168
+ }
1169
+ finally {
1170
+ db.close();
1171
+ }
1172
+ });
1173
+ schedule
1174
+ .command("history")
1175
+ .description("Show schedule run history")
1176
+ .argument("<id>", "Schedule ID")
1177
+ .option("--limit <n>", "Number of runs to show", "20")
1178
+ .action((id, opts) => {
1179
+ const db = new SchedulerDb(join(DATA_DIR, "scheduler.db"));
1180
+ try {
1181
+ const runs = db.getRuns(id, parseInt(opts.limit, 10));
1182
+ if (runs.length === 0) {
1183
+ console.log("No runs found.");
1184
+ return;
1185
+ }
1186
+ console.log("Time\t\t\tStatus\t\t\tDetail");
1187
+ for (const r of runs) {
1188
+ console.log(`${r.triggered_at}\t${r.status}\t${r.detail ?? ""}`);
1189
+ }
1190
+ }
1191
+ finally {
1192
+ db.close();
1193
+ }
1194
+ });
1195
+ schedule
1196
+ .command("trigger")
1197
+ .description("Manually trigger a schedule")
1198
+ .argument("<id>", "Schedule ID")
1199
+ .action((id) => {
1200
+ console.log("Manual trigger requires fleet manager running. Use the Telegram interface instead.");
1201
+ });
1202
+ // === Chat Export ===
1203
+ program
1204
+ .command("export-chat")
1205
+ .description("Export fleet activity as a shareable HTML chat log")
1206
+ .option("--from <time>", "Start time (ISO or HH:MM for today)")
1207
+ .option("--to <time>", "End time (ISO or HH:MM for today)")
1208
+ .option("-o, --output <path>", "Output file path")
1209
+ .action(async (opts) => {
1210
+ const { exportChat } = await import("./chat-export.js");
1211
+ // Resolve HH:MM shorthand to full ISO date (today)
1212
+ const resolveTime = (t) => {
1213
+ if (!t)
1214
+ return undefined;
1215
+ if (/^\d{2}:\d{2}$/.test(t)) {
1216
+ return new Date().toISOString().slice(0, 10) + " " + t + ":00";
1217
+ }
1218
+ return t;
1219
+ };
1220
+ const dbPath = join(DATA_DIR, "events.db");
1221
+ const html = exportChat(dbPath, { from: resolveTime(opts.from), to: resolveTime(opts.to) });
1222
+ const outPath = opts.output ?? `chat-export-${Date.now()}.html`;
1223
+ const { writeFileSync } = await import("node:fs");
1224
+ writeFileSync(outPath, html, "utf-8");
1225
+ console.log(`Chat exported to ${outPath}`);
1226
+ });
1227
+ // === Export / Import ===
1228
+ program
1229
+ .command("export")
1230
+ .description("Export configuration for migration to another device")
1231
+ .argument("[output]", "Output file path")
1232
+ .option("--full", "Include all instance data (not just config)")
1233
+ .action(async (output, opts) => {
1234
+ const { exportConfig } = await import("./export-import.js");
1235
+ await exportConfig(DATA_DIR, output, opts?.full ?? false);
1236
+ });
1237
+ program
1238
+ .command("import")
1239
+ .description("Import configuration from an export file")
1240
+ .argument("<file>", "Path to export tarball")
1241
+ .action(async (file) => {
1242
+ const { importConfig } = await import("./export-import.js");
1243
+ await importConfig(DATA_DIR, file);
1244
+ });
1245
+ // === Quick management commands ===
1246
+ async function fuzzyMatch(query, names) {
1247
+ const q = query.toLowerCase();
1248
+ // Exact match
1249
+ const exact = names.find(n => n.toLowerCase() === q);
1250
+ if (exact)
1251
+ return exact;
1252
+ // Starts with
1253
+ const starts = names.filter(n => n.toLowerCase().startsWith(q));
1254
+ if (starts.length === 1)
1255
+ return starts[0];
1256
+ // Contains
1257
+ const contains = names.filter(n => n.toLowerCase().includes(q));
1258
+ if (contains.length === 1)
1259
+ return contains[0];
1260
+ if (contains.length > 1) {
1261
+ // Interactive selection
1262
+ console.log(`Multiple matches for "${query}":`);
1263
+ for (let i = 0; i < contains.length; i++) {
1264
+ console.log(` ${i + 1}) ${contains[i]}`);
1265
+ }
1266
+ const { createInterface } = await import("node:readline");
1267
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1268
+ const answer = await new Promise(resolve => {
1269
+ rl.question("Select [1]: ", resolve);
1270
+ });
1271
+ rl.close();
1272
+ const idx = parseInt(answer.trim() || "1", 10) - 1;
1273
+ if (idx >= 0 && idx < contains.length)
1274
+ return contains[idx];
1275
+ console.error("Invalid selection.");
1276
+ process.exit(1);
1277
+ }
1278
+ return null;
1279
+ }
1280
+ async function resolveInstance(query, config) {
1281
+ const names = Object.keys(config.instances);
1282
+ // Include classic instances from classicBot.yaml
1283
+ try {
1284
+ const classicPath = join(DATA_DIR, "classicBot.yaml");
1285
+ if (existsSync(classicPath)) {
1286
+ const yamlMod = (await import("js-yaml")).default;
1287
+ const classic = yamlMod.load(readFileSync(classicPath, "utf-8"));
1288
+ if (classic?.channels) {
1289
+ for (const [channelId, val] of Object.entries(classic.channels)) {
1290
+ const chName = (val.name ?? channelId).toLowerCase().replace(/[^\p{L}\d-]/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "project";
1291
+ names.push(`classic-${chName}-${channelId.slice(-4)}`);
1292
+ }
1293
+ }
1294
+ }
1295
+ }
1296
+ catch { /* ignore */ }
1297
+ const match = await fuzzyMatch(query, names);
1298
+ if (!match) {
1299
+ console.error(`No instance matching "${query}". Available: ${names.join(", ")}`);
1300
+ process.exit(1);
1301
+ }
1302
+ return match;
1303
+ }
1304
+ /** Get total RSS (KB) for a process and all its descendants. */
1305
+ function getTreeRssKb(pid, depth = 0) {
1306
+ if (depth > 10)
1307
+ return 0;
1308
+ if (!Number.isInteger(pid) || pid <= 0)
1309
+ return 0;
1310
+ let total = 0;
1311
+ try {
1312
+ const rss = parseInt(execFileSync("ps", ["-o", "rss=", "-p", String(pid)], { stdio: "pipe" }).toString().trim(), 10);
1313
+ if (!isNaN(rss))
1314
+ total += rss;
1315
+ }
1316
+ catch {
1317
+ return 0;
1318
+ }
1319
+ try {
1320
+ const children = execFileSync("pgrep", ["-P", String(pid)], { stdio: "pipe" }).toString().trim();
1321
+ for (const line of children.split("\n")) {
1322
+ const childPid = parseInt(line, 10);
1323
+ if (!isNaN(childPid))
1324
+ total += getTreeRssKb(childPid, depth + 1);
1325
+ }
1326
+ }
1327
+ catch { /* no children */ }
1328
+ return total;
1329
+ }
1330
+ function getInstanceStatusStandalone(name) {
1331
+ const pidPath = join(DATA_DIR, "instances", name, "daemon.pid");
1332
+ if (!existsSync(pidPath))
1333
+ return "stopped";
1334
+ const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
1335
+ try {
1336
+ process.kill(pid, 0);
1337
+ return "running";
1338
+ }
1339
+ catch {
1340
+ return "crashed";
1341
+ }
1342
+ }
1343
+ function stripAnsi(str) {
1344
+ // eslint-disable-next-line no-control-regex
1345
+ return str
1346
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "") // CSI (covers all)
1347
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") // OSC
1348
+ .replace(/\x1b\([A-Z]/g, "") // Character set
1349
+ .replace(/\x1b[=>]/g, "") // Keypad mode
1350
+ .replace(/\r/g, "") // Carriage returns
1351
+ // eslint-disable-next-line no-control-regex
1352
+ .replace(/[\x00-\x08\x0e-\x1f]/g, ""); // Control chars
1353
+ }
1354
+ function getTeamsForInstance(config, instanceName) {
1355
+ if (!config.teams)
1356
+ return [];
1357
+ return Object.entries(config.teams)
1358
+ .filter(([, t]) => t.members.includes(instanceName))
1359
+ .map(([name]) => name);
1360
+ }
1361
+ function formatTimeSince(isoStr) {
1362
+ const diff = Date.now() - new Date(isoStr).getTime();
1363
+ if (diff < 60_000)
1364
+ return `${Math.floor(diff / 1000)}s ago`;
1365
+ if (diff < 3_600_000)
1366
+ return `${Math.floor(diff / 60_000)}m ago`;
1367
+ if (diff < 86_400_000)
1368
+ return `${Math.floor(diff / 3_600_000)}h ago`;
1369
+ return `${Math.floor(diff / 86_400_000)}d ago`;
1370
+ }
1371
+ /** Backend-specific parsers for extracting context usage from tmux pane output. */
1372
+ const contextParsers = {
1373
+ "kiro-cli": (output) => {
1374
+ // Classic mode: "8% !>" | TUI mode: "◔ 1%"
1375
+ const m = output.match(/(\d+)%.*!>/m) || output.match(/◔\s*(\d+)%/);
1376
+ return m ? parseInt(m[1], 10) : null;
1377
+ },
1378
+ };
1379
+ async function lsAction(opts) {
1380
+ const yaml = (await import("js-yaml")).default;
1381
+ const config = yaml.load(readFileSync(FLEET_CONFIG_PATH, "utf-8"));
1382
+ const names = Object.keys(config.instances);
1383
+ // Load classic channels from classicBot.yaml (keyed by channelId)
1384
+ const classicPath = join(DATA_DIR, "classicBot.yaml");
1385
+ let classicConfig = null;
1386
+ try {
1387
+ if (existsSync(classicPath))
1388
+ classicConfig = yaml.load(readFileSync(classicPath, "utf-8"));
1389
+ }
1390
+ catch { /* ignore */ }
1391
+ const allNames = [...names];
1392
+ const classicNames = new Set();
1393
+ const classicBackends = new Map();
1394
+ if (classicConfig?.channels) {
1395
+ const classicDefault = classicConfig.defaults?.backend || config.defaults?.backend || "claude-code";
1396
+ for (const [channelId, val] of Object.entries(classicConfig.channels)) {
1397
+ const chName = (val.name ?? channelId).toLowerCase().replace(/[^\p{L}\d-]/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "project";
1398
+ const suffix = channelId.slice(-4);
1399
+ const iName = `classic-${chName}-${suffix}`;
1400
+ if (!allNames.includes(iName)) {
1401
+ allNames.push(iName);
1402
+ classicNames.add(iName);
1403
+ classicBackends.set(iName, val.backend || classicDefault);
1404
+ }
1405
+ }
1406
+ }
1407
+ if (allNames.length === 0) {
1408
+ console.log("No instances configured.");
1409
+ return;
1410
+ }
1411
+ // Resolve tmux pane PIDs for memory measurement
1412
+ const { TmuxManager } = await import("./tmux-manager.js");
1413
+ const { getTmuxSession } = await import("./config.js");
1414
+ const sessionName = getTmuxSession();
1415
+ const pidByName = new Map();
1416
+ try {
1417
+ const windows = await TmuxManager.listWindows(sessionName);
1418
+ for (const w of windows) {
1419
+ const pid = await TmuxManager.getPanePid(sessionName, w.id);
1420
+ if (pid)
1421
+ pidByName.set(w.name, pid);
1422
+ }
1423
+ }
1424
+ catch { /* tmux not running */ }
1425
+ // Determine platform source for each instance
1426
+ const channelTypes = new Set();
1427
+ if (config.channels?.length) {
1428
+ for (const ch of config.channels)
1429
+ channelTypes.add(ch.type);
1430
+ }
1431
+ else if (config.channel?.type) {
1432
+ channelTypes.add(config.channel.type);
1433
+ }
1434
+ const singleSource = channelTypes.size === 1
1435
+ ? (channelTypes.has("telegram") ? "TG" : channelTypes.has("discord") ? "DC" : "—")
1436
+ : null;
1437
+ const getSource = (name, inst) => {
1438
+ if (singleSource)
1439
+ return singleSource;
1440
+ if (classicNames.has(name)) {
1441
+ // Match channelId by suffix: Telegram IDs are short/negative, Discord snowflakes are 17+ digits
1442
+ const suffix = name.slice(-4);
1443
+ const matchedChId = Object.keys(classicConfig?.channels ?? {}).find(id => id.slice(-4) === suffix) ?? "";
1444
+ return matchedChId.startsWith("-") || matchedChId.length < 17 ? "TG" : "DC";
1445
+ }
1446
+ const topicId = String(inst?.topic_id ?? "");
1447
+ if (topicId.length >= 17)
1448
+ return "DC";
1449
+ if (topicId.length > 0 && topicId.length < 17)
1450
+ return "TG";
1451
+ return "—";
1452
+ };
1453
+ const rows = allNames.map(name => {
1454
+ const isClassic = classicNames.has(name);
1455
+ const status = getInstanceStatusStandalone(name);
1456
+ const teams = isClassic ? ["(classic)"] : getTeamsForInstance(config, name);
1457
+ const inst = config.instances[name];
1458
+ const backend = isClassic
1459
+ ? (classicBackends.get(name) ?? "claude-code")
1460
+ : (inst?.backend ?? config.defaults?.backend ?? "claude-code");
1461
+ const source = getSource(name, inst);
1462
+ // Read statusline for context
1463
+ let context = null;
1464
+ const statusFile = join(DATA_DIR, "instances", name, "statusline.json");
1465
+ try {
1466
+ if (existsSync(statusFile)) {
1467
+ const data = JSON.parse(readFileSync(statusFile, "utf-8"));
1468
+ context = data.context_window?.used_percentage ?? null;
1469
+ }
1470
+ }
1471
+ catch { /* ignore */ }
1472
+ // Fallback: parse context from tmux pane using backend-specific parser
1473
+ if (context == null) {
1474
+ const parser = contextParsers[backend];
1475
+ if (parser) {
1476
+ try {
1477
+ const pane = execFileSync("tmux", tmuxArgs([
1478
+ "capture-pane", "-t", `${sessionName}:${name}`, "-p"
1479
+ ]), { encoding: "utf-8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] });
1480
+ context = parser(pane);
1481
+ }
1482
+ catch { /* tmux capture failed */ }
1483
+ }
1484
+ }
1485
+ // Memory: sum RSS of pane process tree
1486
+ let memMb = null;
1487
+ const panePid = pidByName.get(name);
1488
+ if (panePid) {
1489
+ try {
1490
+ const rssKb = getTreeRssKb(panePid);
1491
+ if (rssKb > 0)
1492
+ memMb = Math.round(rssKb / 1024);
1493
+ }
1494
+ catch { /* ignore */ }
1495
+ }
1496
+ // Last activity: prefer statusline.json mtime (updated on real agent activity)
1497
+ let lastActivity = null;
1498
+ for (const probe of ["statusline.json", "daemon.log", "output.log"]) {
1499
+ const p = join(DATA_DIR, "instances", name, probe);
1500
+ try {
1501
+ if (existsSync(p)) {
1502
+ lastActivity = formatTimeSince(statSync(p).mtime.toISOString());
1503
+ break;
1504
+ }
1505
+ }
1506
+ catch { /* ignore */ }
1507
+ }
1508
+ return { name, backend, status, teams, source, context, memMb, lastActivity };
1509
+ });
1510
+ if (opts.json) {
1511
+ console.log(JSON.stringify(rows, null, 2));
1512
+ return;
1513
+ }
1514
+ // Status icon
1515
+ const statusIcon = (s) => s === "running" ? "\x1b[32m●\x1b[0m" : s === "crashed" ? "\x1b[31m●\x1b[0m" : "\x1b[90m○\x1b[0m";
1516
+ /** Get display width accounting for fullwidth (CJK) characters */
1517
+ const displayWidth = (s) => {
1518
+ let w = 0;
1519
+ for (const ch of s) {
1520
+ const cp = ch.codePointAt(0);
1521
+ w += (cp > 0x7f && cp !== 0x200b) ? 2 : 1;
1522
+ }
1523
+ return w;
1524
+ };
1525
+ const padDisplay = (s, width) => s + " ".repeat(Math.max(0, width - displayWidth(s)));
1526
+ const nameW = Math.max(20, ...rows.map(r => displayWidth(r.name) + 2));
1527
+ const backendW = 14;
1528
+ const statusW = 12;
1529
+ const teamW = 20;
1530
+ const srcW = 4;
1531
+ const ctxW = 8;
1532
+ const memW = 8;
1533
+ console.log("Name".padEnd(nameW) +
1534
+ "Backend".padEnd(backendW) +
1535
+ "Status".padEnd(statusW) +
1536
+ "Team".padEnd(teamW) +
1537
+ "Src".padEnd(srcW) +
1538
+ "Ctx".padEnd(ctxW) +
1539
+ "Mem".padEnd(memW) +
1540
+ "Activity");
1541
+ console.log("\u2500".repeat(nameW + backendW + statusW + teamW + srcW + ctxW + memW + 10));
1542
+ for (const r of rows) {
1543
+ const teamStr = r.teams.length > 0 ? r.teams.join(",") : "-";
1544
+ const ctxStr = r.context != null ? `${Math.round(r.context)}%` : "-";
1545
+ const memStr = r.memMb != null ? `${r.memMb}MB` : "-";
1546
+ const actStr = r.lastActivity ?? "-";
1547
+ console.log(padDisplay(r.name, nameW) +
1548
+ r.backend.padEnd(backendW) +
1549
+ statusIcon(r.status) + " " + r.status.padEnd(statusW - 2) +
1550
+ padDisplay(teamStr, teamW) +
1551
+ r.source.padEnd(srcW) +
1552
+ ctxStr.padEnd(ctxW) +
1553
+ memStr.padEnd(memW) +
1554
+ actStr);
1555
+ }
1556
+ // System memory footer
1557
+ const totalGB = totalmem() / (1024 ** 3);
1558
+ const usedGB = (totalmem() - freemem()) / (1024 ** 3);
1559
+ console.log(`\nSystem Memory: ${usedGB.toFixed(1)} / ${totalGB.toFixed(1)} GB`);
1560
+ }
1561
+ program
1562
+ .command("ls")
1563
+ .description("List all instances with status, backend, team, and last activity")
1564
+ .option("--json", "Output as JSON")
1565
+ .action(async (opts) => {
1566
+ await lsAction(opts);
1567
+ });
1568
+ program
1569
+ .command("health")
1570
+ .description("Fleet health check — shows problems and diagnostics")
1571
+ .option("--json", "Output as JSON")
1572
+ .option("-q, --quiet", "One-line summary only")
1573
+ .action(async (opts) => {
1574
+ const { loadFleetConfig } = await import("./config.js");
1575
+ const { TmuxManager } = await import("./tmux-manager.js");
1576
+ const { getTmuxSession } = await import("./config.js");
1577
+ if (!existsSync(FLEET_CONFIG_PATH)) {
1578
+ console.error("No fleet config found. Run: agend quickstart");
1579
+ process.exit(2);
1580
+ }
1581
+ const config = loadFleetConfig(FLEET_CONFIG_PATH);
1582
+ const port = config.health_port ?? 19280;
1583
+ const sessionName = getTmuxSession();
1584
+ const names = Object.keys(config.instances);
1585
+ // Try HTTP first for rich data, fallback to local files
1586
+ let fleetUp = false;
1587
+ let fleetPid = null;
1588
+ let uptime = 0;
1589
+ let fleetApiData = {};
1590
+ const pidPath = join(DATA_DIR, "fleet.pid");
1591
+ try {
1592
+ const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
1593
+ process.kill(pid, 0);
1594
+ fleetPid = pid;
1595
+ fleetUp = true;
1596
+ }
1597
+ catch { /* fleet not running */ }
1598
+ if (fleetUp) {
1599
+ try {
1600
+ const resp = await fetch(`http://127.0.0.1:${port}/api/fleet`, { signal: AbortSignal.timeout(3000) });
1601
+ const data = await resp.json();
1602
+ uptime = data.uptime_seconds ?? 0;
1603
+ for (const inst of data.instances ?? []) {
1604
+ fleetApiData[inst.name] = { ipc: inst.ipc, rateLimits: inst.rateLimits, lastActivity: inst.lastActivity };
1605
+ }
1606
+ }
1607
+ catch { /* API not reachable, use local data */ }
1608
+ }
1609
+ // Tmux windows
1610
+ const tmuxWindows = new Set();
1611
+ try {
1612
+ const windows = await TmuxManager.listWindows(sessionName);
1613
+ for (const w of windows)
1614
+ tmuxWindows.add(w.name);
1615
+ }
1616
+ catch { /* tmux not running */ }
1617
+ const results = names.map(name => {
1618
+ const instConfig = config.instances[name];
1619
+ const isGeneral = instConfig.general_topic === true;
1620
+ const issues = [];
1621
+ // Process alive?
1622
+ const procStatus = getInstanceStatusStandalone(name);
1623
+ if (procStatus === "crashed") {
1624
+ issues.push("Process dead (daemon.pid stale)");
1625
+ // Check crash-state.json
1626
+ const crashState = join(DATA_DIR, "instances", name, "crash-state.json");
1627
+ if (existsSync(crashState))
1628
+ issues.push("Crash loop detected (crash-state.json present)");
1629
+ return { name, status: "crash", issues, general: isGeneral };
1630
+ }
1631
+ if (procStatus === "stopped") {
1632
+ return { name, status: "stopped", issues: ["Not running"], general: isGeneral };
1633
+ }
1634
+ // Tmux window alive?
1635
+ if (!tmuxWindows.has(name))
1636
+ issues.push("Tmux window missing");
1637
+ // IPC connected? (from API data)
1638
+ const api = fleetApiData[name];
1639
+ if (api && !api.ipc)
1640
+ issues.push("IPC disconnected");
1641
+ // Rate limits
1642
+ const rl = api?.rateLimits;
1643
+ if (rl && rl.five_hour_pct >= 90)
1644
+ issues.push(`Rate limited (5h: ${Math.round(rl.five_hour_pct)}%)`);
1645
+ if (rl && rl.seven_day_pct >= 95)
1646
+ issues.push(`Weekly limit critical (7d: ${Math.round(rl.seven_day_pct)}%)`);
1647
+ // Idle check
1648
+ const lastAct = api?.lastActivity;
1649
+ const idleMs = lastAct ? Date.now() - lastAct : null;
1650
+ const idleHours = idleMs ? idleMs / 3600000 : null;
1651
+ if (idleHours && idleHours > 1)
1652
+ issues.push(`Idle ${Math.round(idleHours)}h`);
1653
+ // Determine status
1654
+ let status = "ok";
1655
+ if (issues.some(i => i.includes("Tmux") || i.includes("IPC")))
1656
+ status = "no-ipc";
1657
+ if (issues.some(i => i.includes("Rate") || i.includes("Weekly")))
1658
+ status = "degraded";
1659
+ if (issues.some(i => i.includes("Idle")) && status === "ok")
1660
+ status = "idle";
1661
+ return { name, status, issues, general: isGeneral };
1662
+ });
1663
+ // Fleet classification
1664
+ const crashed = results.filter(r => r.status === "crash");
1665
+ const problems = results.filter(r => r.status !== "ok" && r.status !== "idle" && r.status !== "stopped");
1666
+ const healthy = results.filter(r => r.status === "ok" || r.status === "idle");
1667
+ const stopped = results.filter(r => r.status === "stopped");
1668
+ const generalDown = results.some(r => r.general && r.status !== "ok" && r.status !== "idle");
1669
+ let classification;
1670
+ if (generalDown || crashed.length > 0)
1671
+ classification = "unhealthy";
1672
+ else if (problems.length > 0)
1673
+ classification = "degraded";
1674
+ else
1675
+ classification = "healthy";
1676
+ const exitCode = classification === "healthy" ? 0 : fleetUp ? 1 : 2;
1677
+ if (opts.json) {
1678
+ console.log(JSON.stringify({ fleet: { running: fleetUp, pid: fleetPid, uptime, classification }, instances: results }, null, 2));
1679
+ process.exit(exitCode);
1680
+ }
1681
+ if (opts.quiet) {
1682
+ const icon = classification === "healthy" ? "✓" : classification === "degraded" ? "⚠" : "✗";
1683
+ console.log(`${icon} ${classification}: ${healthy.length}/${names.length} healthy${problems.length > 0 ? `, ${problems.length} issues` : ""}`);
1684
+ process.exit(exitCode);
1685
+ }
1686
+ // Full output
1687
+ const fleetIcon = fleetUp ? "\x1b[32m●\x1b[0m" : "\x1b[31m●\x1b[0m";
1688
+ const upH = Math.floor(uptime / 3600);
1689
+ const upM = Math.floor((uptime % 3600) / 60);
1690
+ console.log(`Fleet: ${fleetIcon} ${fleetUp ? `running (uptime ${upH}h ${upM}m, PID ${fleetPid})` : "not running"}`);
1691
+ console.log(`Instances: ${healthy.length} healthy, ${problems.length + crashed.length} issues, ${stopped.length} stopped\n`);
1692
+ // Only show instances with problems
1693
+ const unhealthy = results.filter(r => r.issues.length > 0 && r.status !== "stopped");
1694
+ if (unhealthy.length === 0) {
1695
+ console.log("\x1b[32m✓ All instances healthy\x1b[0m");
1696
+ }
1697
+ else {
1698
+ for (const inst of unhealthy) {
1699
+ const icon = inst.status === "crash" ? "\x1b[31m✗\x1b[0m" : "\x1b[33m⚠\x1b[0m";
1700
+ console.log(`${icon} ${inst.name}${inst.general ? " (general)" : ""}`);
1701
+ for (const issue of inst.issues) {
1702
+ console.log(` ${issue}`);
1703
+ }
1704
+ }
1705
+ }
1706
+ const classIcon = classification === "healthy" ? "\x1b[32m✓\x1b[0m" : classification === "degraded" ? "\x1b[33m⚠\x1b[0m" : "\x1b[31m✗\x1b[0m";
1707
+ console.log(`\n${classIcon} Fleet: ${classification}`);
1708
+ process.exit(exitCode);
1709
+ });
1710
+ program
1711
+ .command("attach")
1712
+ .description("Attach to an instance's tmux window (fuzzy match)")
1713
+ .argument("<name>", "Instance name (supports fuzzy matching)")
1714
+ .action(async (query) => {
1715
+ const yaml = (await import("js-yaml")).default;
1716
+ const config = yaml.load(readFileSync(FLEET_CONFIG_PATH, "utf-8"));
1717
+ const name = await resolveInstance(query, config);
1718
+ const status = getInstanceStatusStandalone(name);
1719
+ if (status !== "running") {
1720
+ console.error(`Instance "${name}" is ${status}. Start it first with: agend fleet start ${name}`);
1721
+ process.exit(1);
1722
+ }
1723
+ // Read window-id for the instance
1724
+ const windowIdPath = join(DATA_DIR, "instances", name, "window-id");
1725
+ let windowId = null;
1726
+ try {
1727
+ if (existsSync(windowIdPath)) {
1728
+ windowId = readFileSync(windowIdPath, "utf-8").trim();
1729
+ }
1730
+ }
1731
+ catch { /* ignore */ }
1732
+ const session = "agend";
1733
+ // Verify tmux session exists
1734
+ try {
1735
+ execFileSync("tmux", tmuxArgs(["has-session", "-t", session]), { stdio: "pipe" });
1736
+ }
1737
+ catch {
1738
+ console.error(`tmux session "${session}" not found. Is the fleet running?`);
1739
+ process.exit(1);
1740
+ }
1741
+ // Try window-id first (precise), then window name (fallback for stale id)
1742
+ const targets = windowId
1743
+ ? [`${session}:${windowId}`, `${session}:${name}`]
1744
+ : [`${session}:${name}`];
1745
+ let selected = false;
1746
+ for (const t of targets) {
1747
+ try {
1748
+ execFileSync("tmux", tmuxArgs(["select-window", "-t", t]), { stdio: "pipe" });
1749
+ selected = true;
1750
+ break;
1751
+ }
1752
+ catch { /* try next */ }
1753
+ }
1754
+ // Fallback: search tmux windows for a partial name match (handles CJK truncation)
1755
+ if (!selected) {
1756
+ try {
1757
+ const winList = execFileSync("tmux", tmuxArgs(["list-windows", "-t", session, "-F", "#{window_id} #{window_name}"]), { stdio: "pipe" }).toString().trim();
1758
+ for (const line of winList.split("\n")) {
1759
+ const [wid, ...rest] = line.split(" ");
1760
+ const wname = rest.join(" ").replace(/-?\*?$/, ""); // strip trailing -/* markers
1761
+ if (wname === name || name.startsWith(wname) || wname.startsWith(name)) {
1762
+ try {
1763
+ execFileSync("tmux", tmuxArgs(["select-window", "-t", `${session}:${wid}`]), { stdio: "pipe" });
1764
+ selected = true;
1765
+ break;
1766
+ }
1767
+ catch { /* try next */ }
1768
+ }
1769
+ }
1770
+ }
1771
+ catch { /* ignore */ }
1772
+ }
1773
+ if (!selected) {
1774
+ console.error(`Cannot find tmux window for "${name}". The instance may need to be restarted.`);
1775
+ process.exit(1);
1776
+ }
1777
+ // Attach or switch-client depending on whether we're already in tmux
1778
+ if (process.env.TMUX) {
1779
+ // Already inside tmux — switch client
1780
+ try {
1781
+ execFileSync("tmux", tmuxArgs(["switch-client", "-t", session]), { stdio: "inherit" });
1782
+ }
1783
+ catch {
1784
+ console.error("Failed to switch tmux client.");
1785
+ process.exit(1);
1786
+ }
1787
+ }
1788
+ else {
1789
+ // Outside tmux — attach
1790
+ try {
1791
+ execFileSync("tmux", tmuxArgs(["attach-session", "-t", session]), { stdio: "inherit" });
1792
+ }
1793
+ catch {
1794
+ console.error("Failed to attach to tmux session.");
1795
+ process.exit(1);
1796
+ }
1797
+ }
1798
+ });
1799
+ program
1800
+ .command("logs")
1801
+ .description("Show fleet log (alias for `agend fleet logs`)")
1802
+ .option("-n, --lines <count>", "Number of lines to show", "50")
1803
+ .option("-f, --follow", "Follow log output (like tail -f)")
1804
+ .option("--instance <name>", "Filter by instance name")
1805
+ .action((opts) => {
1806
+ const logPath = join(DATA_DIR, "fleet.log");
1807
+ if (!existsSync(logPath)) {
1808
+ console.error("No fleet log found. Is the fleet running?");
1809
+ process.exit(1);
1810
+ }
1811
+ if (opts.follow) {
1812
+ const tailArgs = ["-n", opts.lines, "-f", logPath];
1813
+ const tail = spawn("tail", tailArgs, { stdio: ["ignore", "pipe", "inherit"] });
1814
+ tail.stdout.on("data", (chunk) => {
1815
+ const lines = stripAnsi(chunk.toString()).split("\n");
1816
+ for (const line of lines) {
1817
+ if (!opts.instance || line.includes(opts.instance))
1818
+ process.stdout.write(line + "\n");
1819
+ }
1820
+ });
1821
+ tail.on("close", () => process.exit(0));
1822
+ process.on("SIGINT", () => { tail.kill(); process.exit(0); });
1823
+ return;
1824
+ }
1825
+ const content = readFileSync(logPath, "utf-8");
1826
+ let lines = content.trim().split("\n");
1827
+ if (opts.instance)
1828
+ lines = lines.filter(l => l.includes(opts.instance));
1829
+ const n = parseInt(opts.lines, 10);
1830
+ console.log(stripAnsi(lines.slice(-n).join("\n")));
1831
+ });
1832
+ program.parse();
1833
+ //# sourceMappingURL=cli.js.map