@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.
- package/README.md +210 -0
- package/README.zh-TW.md +134 -0
- package/dist/access-path.d.ts +10 -0
- package/dist/access-path.js +32 -0
- package/dist/access-path.js.map +1 -0
- package/dist/adapter-world.d.ts +25 -0
- package/dist/adapter-world.js +41 -0
- package/dist/adapter-world.js.map +1 -0
- package/dist/agent-cli-instructions.md +50 -0
- package/dist/agent-cli.d.ts +2 -0
- package/dist/agent-cli.js +200 -0
- package/dist/agent-cli.js.map +1 -0
- package/dist/agent-endpoint.d.ts +25 -0
- package/dist/agent-endpoint.js +162 -0
- package/dist/agent-endpoint.js.map +1 -0
- package/dist/backend/antigravity.d.ts +17 -0
- package/dist/backend/antigravity.js +98 -0
- package/dist/backend/antigravity.js.map +1 -0
- package/dist/backend/claude-code.d.ts +23 -0
- package/dist/backend/claude-code.js +171 -0
- package/dist/backend/claude-code.js.map +1 -0
- package/dist/backend/codex.d.ts +18 -0
- package/dist/backend/codex.js +160 -0
- package/dist/backend/codex.js.map +1 -0
- package/dist/backend/factory.d.ts +2 -0
- package/dist/backend/factory.js +28 -0
- package/dist/backend/factory.js.map +1 -0
- package/dist/backend/gemini-cli.d.ts +17 -0
- package/dist/backend/gemini-cli.js +163 -0
- package/dist/backend/gemini-cli.js.map +1 -0
- package/dist/backend/index.d.ts +7 -0
- package/dist/backend/index.js +7 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/kiro.d.ts +17 -0
- package/dist/backend/kiro.js +147 -0
- package/dist/backend/kiro.js.map +1 -0
- package/dist/backend/marker-utils.d.ts +13 -0
- package/dist/backend/marker-utils.js +64 -0
- package/dist/backend/marker-utils.js.map +1 -0
- package/dist/backend/mock.d.ts +25 -0
- package/dist/backend/mock.js +85 -0
- package/dist/backend/mock.js.map +1 -0
- package/dist/backend/opencode.d.ts +16 -0
- package/dist/backend/opencode.js +136 -0
- package/dist/backend/opencode.js.map +1 -0
- package/dist/backend/types.d.ts +86 -0
- package/dist/backend/types.js +33 -0
- package/dist/backend/types.js.map +1 -0
- package/dist/channel/access-manager.d.ts +18 -0
- package/dist/channel/access-manager.js +153 -0
- package/dist/channel/access-manager.js.map +1 -0
- package/dist/channel/adapters/telegram.d.ts +63 -0
- package/dist/channel/adapters/telegram.js +646 -0
- package/dist/channel/adapters/telegram.js.map +1 -0
- package/dist/channel/attachment-handler.d.ts +15 -0
- package/dist/channel/attachment-handler.js +88 -0
- package/dist/channel/attachment-handler.js.map +1 -0
- package/dist/channel/factory.d.ts +12 -0
- package/dist/channel/factory.js +67 -0
- package/dist/channel/factory.js.map +1 -0
- package/dist/channel/ipc-bridge.d.ts +26 -0
- package/dist/channel/ipc-bridge.js +220 -0
- package/dist/channel/ipc-bridge.js.map +1 -0
- package/dist/channel/mcp-server.d.ts +10 -0
- package/dist/channel/mcp-server.js +288 -0
- package/dist/channel/mcp-server.js.map +1 -0
- package/dist/channel/mcp-tools.d.ts +17 -0
- package/dist/channel/mcp-tools.js +110 -0
- package/dist/channel/mcp-tools.js.map +1 -0
- package/dist/channel/message-bus.d.ts +17 -0
- package/dist/channel/message-bus.js +86 -0
- package/dist/channel/message-bus.js.map +1 -0
- package/dist/channel/message-queue.d.ts +39 -0
- package/dist/channel/message-queue.js +253 -0
- package/dist/channel/message-queue.js.map +1 -0
- package/dist/channel/tool-router.d.ts +6 -0
- package/dist/channel/tool-router.js +75 -0
- package/dist/channel/tool-router.js.map +1 -0
- package/dist/channel/tool-tracker.d.ts +13 -0
- package/dist/channel/tool-tracker.js +58 -0
- package/dist/channel/tool-tracker.js.map +1 -0
- package/dist/channel/types.d.ts +118 -0
- package/dist/channel/types.js +2 -0
- package/dist/channel/types.js.map +1 -0
- package/dist/chat-export.d.ts +4 -0
- package/dist/chat-export.js +91 -0
- package/dist/chat-export.js.map +1 -0
- package/dist/classic-channel-manager.d.ts +59 -0
- package/dist/classic-channel-manager.js +193 -0
- package/dist/classic-channel-manager.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1833 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +118 -0
- package/dist/config.js.map +1 -0
- package/dist/context-guardian.d.ts +26 -0
- package/dist/context-guardian.js +73 -0
- package/dist/context-guardian.js.map +1 -0
- package/dist/cost-guard.d.ts +36 -0
- package/dist/cost-guard.js +147 -0
- package/dist/cost-guard.js.map +1 -0
- package/dist/daemon-entry.d.ts +1 -0
- package/dist/daemon-entry.js +29 -0
- package/dist/daemon-entry.js.map +1 -0
- package/dist/daemon.d.ts +152 -0
- package/dist/daemon.js +1714 -0
- package/dist/daemon.js.map +1 -0
- package/dist/daily-summary.d.ts +13 -0
- package/dist/daily-summary.js +55 -0
- package/dist/daily-summary.js.map +1 -0
- package/dist/event-log.d.ts +36 -0
- package/dist/event-log.js +100 -0
- package/dist/event-log.js.map +1 -0
- package/dist/export-import.d.ts +2 -0
- package/dist/export-import.js +162 -0
- package/dist/export-import.js.map +1 -0
- package/dist/fleet-context.d.ts +61 -0
- package/dist/fleet-context.js +4 -0
- package/dist/fleet-context.js.map +1 -0
- package/dist/fleet-dashboard-html.d.ts +6 -0
- package/dist/fleet-dashboard-html.js +443 -0
- package/dist/fleet-dashboard-html.js.map +1 -0
- package/dist/fleet-health-server.d.ts +35 -0
- package/dist/fleet-health-server.js +290 -0
- package/dist/fleet-health-server.js.map +1 -0
- package/dist/fleet-instructions.d.ts +5 -0
- package/dist/fleet-instructions.js +161 -0
- package/dist/fleet-instructions.js.map +1 -0
- package/dist/fleet-manager.d.ts +212 -0
- package/dist/fleet-manager.js +3655 -0
- package/dist/fleet-manager.js.map +1 -0
- package/dist/fleet-rpc-handlers.d.ts +42 -0
- package/dist/fleet-rpc-handlers.js +356 -0
- package/dist/fleet-rpc-handlers.js.map +1 -0
- package/dist/fleet-system-prompt.d.ts +11 -0
- package/dist/fleet-system-prompt.js +61 -0
- package/dist/fleet-system-prompt.js.map +1 -0
- package/dist/general-knowledge/skills.md +177 -0
- package/dist/hang-detector.d.ts +16 -0
- package/dist/hang-detector.js +53 -0
- package/dist/hang-detector.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/instance-lifecycle.d.ts +90 -0
- package/dist/instance-lifecycle.js +592 -0
- package/dist/instance-lifecycle.js.map +1 -0
- package/dist/instructions.d.ts +15 -0
- package/dist/instructions.js +90 -0
- package/dist/instructions.js.map +1 -0
- package/dist/logger.d.ts +7 -0
- package/dist/logger.js +84 -0
- package/dist/logger.js.map +1 -0
- package/dist/outbound-handlers.d.ts +51 -0
- package/dist/outbound-handlers.js +739 -0
- package/dist/outbound-handlers.js.map +1 -0
- package/dist/outbound-schemas.d.ts +238 -0
- package/dist/outbound-schemas.js +248 -0
- package/dist/outbound-schemas.js.map +1 -0
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +42 -0
- package/dist/paths.js.map +1 -0
- package/dist/plugin/agend/.claude-plugin/plugin.json +5 -0
- package/dist/quickstart.d.ts +1 -0
- package/dist/quickstart.js +595 -0
- package/dist/quickstart.js.map +1 -0
- package/dist/routing-engine.d.ts +22 -0
- package/dist/routing-engine.js +44 -0
- package/dist/routing-engine.js.map +1 -0
- package/dist/safe-async.d.ts +6 -0
- package/dist/safe-async.js +20 -0
- package/dist/safe-async.js.map +1 -0
- package/dist/scheduler/db.d.ts +37 -0
- package/dist/scheduler/db.js +360 -0
- package/dist/scheduler/db.js.map +1 -0
- package/dist/scheduler/db.test.d.ts +1 -0
- package/dist/scheduler/db.test.js +92 -0
- package/dist/scheduler/db.test.js.map +1 -0
- package/dist/scheduler/index.d.ts +4 -0
- package/dist/scheduler/index.js +4 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/scheduler/scheduler.d.ts +44 -0
- package/dist/scheduler/scheduler.js +197 -0
- package/dist/scheduler/scheduler.js.map +1 -0
- package/dist/scheduler/scheduler.test.d.ts +1 -0
- package/dist/scheduler/scheduler.test.js +119 -0
- package/dist/scheduler/scheduler.test.js.map +1 -0
- package/dist/scheduler/types.d.ts +107 -0
- package/dist/scheduler/types.js +7 -0
- package/dist/scheduler/types.js.map +1 -0
- package/dist/service-installer.d.ts +17 -0
- package/dist/service-installer.js +182 -0
- package/dist/service-installer.js.map +1 -0
- package/dist/setup-wizard.d.ts +48 -0
- package/dist/setup-wizard.js +701 -0
- package/dist/setup-wizard.js.map +1 -0
- package/dist/statusline-watcher.d.ts +34 -0
- package/dist/statusline-watcher.js +73 -0
- package/dist/statusline-watcher.js.map +1 -0
- package/dist/stt.d.ts +10 -0
- package/dist/stt.js +33 -0
- package/dist/stt.js.map +1 -0
- package/dist/tmux-control.d.ts +52 -0
- package/dist/tmux-control.js +207 -0
- package/dist/tmux-control.js.map +1 -0
- package/dist/tmux-manager.d.ts +44 -0
- package/dist/tmux-manager.js +218 -0
- package/dist/tmux-manager.js.map +1 -0
- package/dist/topic-archiver.d.ts +40 -0
- package/dist/topic-archiver.js +103 -0
- package/dist/topic-archiver.js.map +1 -0
- package/dist/topic-commands.d.ts +28 -0
- package/dist/topic-commands.js +359 -0
- package/dist/topic-commands.js.map +1 -0
- package/dist/transcript-monitor.d.ts +23 -0
- package/dist/transcript-monitor.js +164 -0
- package/dist/transcript-monitor.js.map +1 -0
- package/dist/types.d.ts +211 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/dashboard.html +719 -0
- package/dist/web-api.d.ts +101 -0
- package/dist/web-api.js +648 -0
- package/dist/web-api.js.map +1 -0
- package/dist/webhook-emitter.d.ts +15 -0
- package/dist/webhook-emitter.js +41 -0
- package/dist/webhook-emitter.js.map +1 -0
- package/dist/workflow-templates/default.md +35 -0
- package/package.json +76 -0
- package/templates/launchd.plist.ejs +31 -0
- 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, "&").replace(/"/g, """);
|
|
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
|