@virtengine/openfleet 0.25.0
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/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
package/cli.mjs
ADDED
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* openfleet — CLI Entry Point
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* openfleet # start with default config
|
|
8
|
+
* openfleet --setup # run setup wizard
|
|
9
|
+
* openfleet --args "-MaxParallel 6" # pass orchestrator args
|
|
10
|
+
* openfleet --help # show help
|
|
11
|
+
*
|
|
12
|
+
* The CLI handles:
|
|
13
|
+
* 1. First-run detection → auto-launches setup wizard
|
|
14
|
+
* 2. Command routing (setup, help, version, main start)
|
|
15
|
+
* 3. Configuration loading from config.mjs
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { resolve, dirname } from "node:path";
|
|
19
|
+
import {
|
|
20
|
+
existsSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
unlinkSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
import { fork, spawn } from "node:child_process";
|
|
28
|
+
import os from "node:os";
|
|
29
|
+
import { createDaemonCrashTracker } from "./daemon-restart-policy.mjs";
|
|
30
|
+
|
|
31
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const args = process.argv.slice(2);
|
|
33
|
+
|
|
34
|
+
function getArgValue(flag) {
|
|
35
|
+
const match = args.find((arg) => arg.startsWith(`${flag}=`));
|
|
36
|
+
if (match) {
|
|
37
|
+
return match.slice(flag.length + 1).trim();
|
|
38
|
+
}
|
|
39
|
+
const idx = args.indexOf(flag);
|
|
40
|
+
if (idx >= 0 && args[idx + 1] && !args[idx + 1].startsWith("--")) {
|
|
41
|
+
return args[idx + 1].trim();
|
|
42
|
+
}
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Version (read from package.json — single source of truth) ────────────────
|
|
47
|
+
|
|
48
|
+
const VERSION = JSON.parse(
|
|
49
|
+
readFileSync(resolve(__dirname, "package.json"), "utf8"),
|
|
50
|
+
).version;
|
|
51
|
+
|
|
52
|
+
// ── Help ─────────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
function showHelp() {
|
|
55
|
+
console.log(`
|
|
56
|
+
openfleet v${VERSION}
|
|
57
|
+
AI-powered orchestrator supervisor with executor failover, smart PR flow, and Telegram notifications.
|
|
58
|
+
|
|
59
|
+
USAGE
|
|
60
|
+
openfleet [options]
|
|
61
|
+
|
|
62
|
+
COMMANDS
|
|
63
|
+
--setup Run the interactive setup wizard
|
|
64
|
+
--doctor Validate openfleet .env/config setup
|
|
65
|
+
--help Show this help
|
|
66
|
+
--version Show version
|
|
67
|
+
--update Check for and install latest version
|
|
68
|
+
--no-update-check Skip automatic update check on startup
|
|
69
|
+
--no-auto-update Disable background auto-update polling
|
|
70
|
+
--daemon, -d Run as a background daemon (detached, with PID file)
|
|
71
|
+
--stop-daemon Stop a running daemon process
|
|
72
|
+
--daemon-status Check if daemon is running
|
|
73
|
+
|
|
74
|
+
ORCHESTRATOR
|
|
75
|
+
--script <path> Path to the orchestrator script
|
|
76
|
+
--args "<args>" Arguments passed to the script (default: "-MaxParallel 6")
|
|
77
|
+
--restart-delay <ms> Delay before restart (default: 10000)
|
|
78
|
+
--max-restarts <n> Max restarts, 0 = unlimited (default: 0)
|
|
79
|
+
|
|
80
|
+
LOGGING
|
|
81
|
+
--log-dir <path> Log directory (default: ./logs)
|
|
82
|
+
--echo-logs Echo raw orchestrator output to console (off by default)
|
|
83
|
+
--quiet, -q Only show warnings and errors in terminal
|
|
84
|
+
--verbose, -V Show debug-level messages in terminal
|
|
85
|
+
--trace Show all messages including trace-level
|
|
86
|
+
--log-level <level> Set explicit log level (trace|debug|info|warn|error|silent)
|
|
87
|
+
|
|
88
|
+
AI / CODEX
|
|
89
|
+
--no-codex Disable Codex SDK analysis
|
|
90
|
+
--no-autofix Disable automatic error fixing
|
|
91
|
+
--primary-agent <name> Override primary agent (codex|copilot|claude)
|
|
92
|
+
--shell, --interactive Enable interactive shell mode in monitor
|
|
93
|
+
|
|
94
|
+
TELEGRAM
|
|
95
|
+
--no-telegram-bot Disable the interactive Telegram bot
|
|
96
|
+
--telegram-commands Enable monitor-side Telegram polling (advanced)
|
|
97
|
+
|
|
98
|
+
WHATSAPP
|
|
99
|
+
--whatsapp-auth Run WhatsApp authentication (QR code mode)
|
|
100
|
+
--whatsapp-auth --pairing-code Authenticate via pairing code instead of QR
|
|
101
|
+
|
|
102
|
+
CONTAINERS
|
|
103
|
+
Container support is configured via environment variables:
|
|
104
|
+
CONTAINER_ENABLED=1 Enable container isolation for agent execution
|
|
105
|
+
CONTAINER_RUNTIME=docker Runtime to use (docker|podman|container)
|
|
106
|
+
|
|
107
|
+
VIBE-KANBAN
|
|
108
|
+
--no-vk-spawn Don't auto-spawn Vibe-Kanban
|
|
109
|
+
--vk-ensure-interval <ms> VK health check interval (default: 60000)
|
|
110
|
+
|
|
111
|
+
STARTUP SERVICE
|
|
112
|
+
--enable-startup Register openfleet to auto-start on login
|
|
113
|
+
--disable-startup Remove openfleet from startup services
|
|
114
|
+
--startup-status Check if startup service is installed
|
|
115
|
+
|
|
116
|
+
SENTINEL
|
|
117
|
+
--sentinel Start telegram-sentinel in companion mode
|
|
118
|
+
--sentinel-stop Stop a running sentinel
|
|
119
|
+
--sentinel-status Show sentinel status
|
|
120
|
+
|
|
121
|
+
FILE WATCHING
|
|
122
|
+
--no-watch Disable file watching for auto-restart
|
|
123
|
+
--watch-path <path> File to watch (default: script path)
|
|
124
|
+
|
|
125
|
+
CONFIGURATION
|
|
126
|
+
--config-dir <path> Directory containing config files
|
|
127
|
+
--repo-root <path> Repository root (auto-detected)
|
|
128
|
+
--project-name <name> Project name for display
|
|
129
|
+
--repo <org/repo> GitHub repo slug
|
|
130
|
+
--repo-name <name> Select repository from multi-repo config
|
|
131
|
+
--profile <name> Environment profile selection
|
|
132
|
+
--mode <name> Override mode (virtengine/generic)
|
|
133
|
+
|
|
134
|
+
ENVIRONMENT
|
|
135
|
+
Configuration is loaded from (in priority order):
|
|
136
|
+
1. CLI flags
|
|
137
|
+
2. Environment variables
|
|
138
|
+
3. .env file
|
|
139
|
+
4. openfleet.config.json
|
|
140
|
+
5. Built-in defaults
|
|
141
|
+
|
|
142
|
+
Auto-update environment variables:
|
|
143
|
+
CODEX_MONITOR_SKIP_UPDATE_CHECK=1 Disable startup version check
|
|
144
|
+
CODEX_MONITOR_SKIP_AUTO_UPDATE=1 Disable background polling
|
|
145
|
+
CODEX_MONITOR_UPDATE_INTERVAL_MS=N Override poll interval (default: 600000)
|
|
146
|
+
|
|
147
|
+
See .env.example for all environment variables.
|
|
148
|
+
|
|
149
|
+
EXECUTOR CONFIG (openfleet.config.json)
|
|
150
|
+
{
|
|
151
|
+
"projectName": "my-project",
|
|
152
|
+
"executors": [
|
|
153
|
+
{ "name": "copilot-claude", "executor": "COPILOT", "variant": "CLAUDE_OPUS_4_6", "weight": 50, "role": "primary" },
|
|
154
|
+
{ "name": "codex-default", "executor": "CODEX", "variant": "DEFAULT", "weight": 50, "role": "backup" }
|
|
155
|
+
],
|
|
156
|
+
"failover": {
|
|
157
|
+
"strategy": "next-in-line",
|
|
158
|
+
"maxRetries": 3,
|
|
159
|
+
"cooldownMinutes": 5,
|
|
160
|
+
"disableOnConsecutiveFailures": 3
|
|
161
|
+
},
|
|
162
|
+
"distribution": "weighted"
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
EXECUTOR ENV SHORTHAND
|
|
166
|
+
EXECUTORS=COPILOT:CLAUDE_OPUS_4_6:50,CODEX:DEFAULT:50
|
|
167
|
+
|
|
168
|
+
EXAMPLES
|
|
169
|
+
openfleet # start with defaults
|
|
170
|
+
openfleet --setup # interactive setup
|
|
171
|
+
openfleet --script ./my-orchestrator.sh # custom script
|
|
172
|
+
openfleet --args "-MaxParallel 4" --no-telegram-bot # custom args
|
|
173
|
+
openfleet --no-codex --no-autofix # minimal mode
|
|
174
|
+
|
|
175
|
+
DOCS
|
|
176
|
+
https://www.npmjs.com/package/@virtengine/openfleet
|
|
177
|
+
`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
// ── Daemon Mode ──────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
const PID_FILE = resolve(__dirname, ".cache", "openfleet.pid");
|
|
185
|
+
const DAEMON_LOG = resolve(__dirname, "logs", "daemon.log");
|
|
186
|
+
const SENTINEL_PID_FILE = resolve(
|
|
187
|
+
__dirname,
|
|
188
|
+
"..",
|
|
189
|
+
"..",
|
|
190
|
+
".cache",
|
|
191
|
+
"telegram-sentinel.pid",
|
|
192
|
+
);
|
|
193
|
+
const SENTINEL_PID_FILE_LEGACY = resolve(
|
|
194
|
+
__dirname,
|
|
195
|
+
".cache",
|
|
196
|
+
"telegram-sentinel.pid",
|
|
197
|
+
);
|
|
198
|
+
const SENTINEL_SCRIPT_PATH = fileURLToPath(
|
|
199
|
+
new URL("./telegram-sentinel.mjs", import.meta.url),
|
|
200
|
+
);
|
|
201
|
+
const IS_DAEMON_CHILD =
|
|
202
|
+
args.includes("--daemon-child") || process.env.CODEX_MONITOR_DAEMON === "1";
|
|
203
|
+
const DAEMON_RESTART_DELAY_MS = Math.max(
|
|
204
|
+
1000,
|
|
205
|
+
Number(process.env.CODEX_MONITOR_DAEMON_RESTART_DELAY_MS || 5000) || 5000,
|
|
206
|
+
);
|
|
207
|
+
const DAEMON_MAX_RESTARTS = Math.max(
|
|
208
|
+
0,
|
|
209
|
+
Number(process.env.CODEX_MONITOR_DAEMON_MAX_RESTARTS || 0) || 0,
|
|
210
|
+
);
|
|
211
|
+
const DAEMON_INSTANT_CRASH_WINDOW_MS = Math.max(
|
|
212
|
+
1000,
|
|
213
|
+
Number(process.env.CODEX_MONITOR_DAEMON_INSTANT_CRASH_WINDOW_MS || 15000) ||
|
|
214
|
+
15000,
|
|
215
|
+
);
|
|
216
|
+
const DAEMON_MAX_INSTANT_RESTARTS = Math.max(
|
|
217
|
+
1,
|
|
218
|
+
Number(process.env.CODEX_MONITOR_DAEMON_MAX_INSTANT_RESTARTS || 3) || 3,
|
|
219
|
+
);
|
|
220
|
+
let daemonRestartCount = 0;
|
|
221
|
+
const daemonCrashTracker = createDaemonCrashTracker({
|
|
222
|
+
instantCrashWindowMs: DAEMON_INSTANT_CRASH_WINDOW_MS,
|
|
223
|
+
maxInstantCrashes: DAEMON_MAX_INSTANT_RESTARTS,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
function isProcessAlive(pid) {
|
|
227
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
228
|
+
try {
|
|
229
|
+
process.kill(pid, 0);
|
|
230
|
+
return true;
|
|
231
|
+
} catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function readAlivePid(pidFile) {
|
|
237
|
+
try {
|
|
238
|
+
if (!existsSync(pidFile)) return null;
|
|
239
|
+
const raw = readFileSync(pidFile, "utf8").trim();
|
|
240
|
+
const pid = Number(raw);
|
|
241
|
+
if (!Number.isFinite(pid) || pid <= 0) return null;
|
|
242
|
+
return isProcessAlive(pid) ? pid : null;
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function parseBoolEnv(val, fallback = false) {
|
|
249
|
+
if (val == null || String(val).trim() === "") return fallback;
|
|
250
|
+
return ["1", "true", "yes", "on"].includes(String(val).toLowerCase());
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function sleep(ms) {
|
|
254
|
+
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function runSentinelCli(flag) {
|
|
258
|
+
return await new Promise((resolveExit) => {
|
|
259
|
+
const child = spawn(process.execPath, [SENTINEL_SCRIPT_PATH, flag], {
|
|
260
|
+
stdio: "inherit",
|
|
261
|
+
env: { ...process.env },
|
|
262
|
+
cwd: process.cwd(),
|
|
263
|
+
});
|
|
264
|
+
child.on("error", () => resolveExit(1));
|
|
265
|
+
child.on("exit", (code) => resolveExit(code ?? 1));
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function ensureSentinelRunning(options = {}) {
|
|
270
|
+
const { quiet = false } = options;
|
|
271
|
+
const existing =
|
|
272
|
+
readAlivePid(SENTINEL_PID_FILE) || readAlivePid(SENTINEL_PID_FILE_LEGACY);
|
|
273
|
+
if (existing) {
|
|
274
|
+
if (!quiet) {
|
|
275
|
+
console.log(` telegram-sentinel already running (PID ${existing})`);
|
|
276
|
+
}
|
|
277
|
+
return { ok: true, pid: existing, alreadyRunning: true };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const child = spawn(process.execPath, [SENTINEL_SCRIPT_PATH], {
|
|
281
|
+
detached: true,
|
|
282
|
+
stdio: "ignore",
|
|
283
|
+
windowsHide: process.platform === "win32",
|
|
284
|
+
env: {
|
|
285
|
+
...process.env,
|
|
286
|
+
CODEX_MONITOR_SENTINEL_COMPANION: "1",
|
|
287
|
+
},
|
|
288
|
+
cwd: process.cwd(),
|
|
289
|
+
});
|
|
290
|
+
child.unref();
|
|
291
|
+
|
|
292
|
+
const spawnedPid = child.pid;
|
|
293
|
+
if (!spawnedPid) {
|
|
294
|
+
return { ok: false, error: "sentinel spawn returned no PID" };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const timeoutAt = Date.now() + 5000;
|
|
298
|
+
while (Date.now() < timeoutAt) {
|
|
299
|
+
await sleep(200);
|
|
300
|
+
const pid =
|
|
301
|
+
readAlivePid(SENTINEL_PID_FILE) || readAlivePid(SENTINEL_PID_FILE_LEGACY);
|
|
302
|
+
if (pid) {
|
|
303
|
+
if (!quiet) {
|
|
304
|
+
console.log(` telegram-sentinel started (PID ${pid})`);
|
|
305
|
+
}
|
|
306
|
+
return { ok: true, pid, alreadyRunning: false };
|
|
307
|
+
}
|
|
308
|
+
if (!isProcessAlive(spawnedPid)) {
|
|
309
|
+
return {
|
|
310
|
+
ok: false,
|
|
311
|
+
error: "telegram-sentinel exited during startup",
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
error: "timed out waiting for telegram-sentinel to become healthy",
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function getDaemonPid() {
|
|
323
|
+
try {
|
|
324
|
+
if (!existsSync(PID_FILE)) return null;
|
|
325
|
+
const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
|
|
326
|
+
if (isNaN(pid)) return null;
|
|
327
|
+
// Check if process is alive
|
|
328
|
+
try {
|
|
329
|
+
process.kill(pid, 0);
|
|
330
|
+
return pid;
|
|
331
|
+
} catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function writePidFile(pid) {
|
|
340
|
+
try {
|
|
341
|
+
mkdirSync(dirname(PID_FILE), { recursive: true });
|
|
342
|
+
writeFileSync(PID_FILE, String(pid), "utf8");
|
|
343
|
+
} catch {
|
|
344
|
+
/* best effort */
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function removePidFile() {
|
|
349
|
+
try {
|
|
350
|
+
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
|
|
351
|
+
} catch {
|
|
352
|
+
/* ok */
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function startDaemon() {
|
|
357
|
+
const existing = getDaemonPid();
|
|
358
|
+
if (existing) {
|
|
359
|
+
console.log(` openfleet daemon is already running (PID ${existing})`);
|
|
360
|
+
console.log(` Use --stop-daemon to stop it first.`);
|
|
361
|
+
process.exit(1);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Ensure log directory exists
|
|
365
|
+
try {
|
|
366
|
+
mkdirSync(dirname(DAEMON_LOG), { recursive: true });
|
|
367
|
+
} catch {
|
|
368
|
+
/* ok */
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const child = spawn(
|
|
372
|
+
process.execPath,
|
|
373
|
+
[
|
|
374
|
+
"--max-old-space-size=4096",
|
|
375
|
+
fileURLToPath(new URL("./cli.mjs", import.meta.url)),
|
|
376
|
+
...process.argv.slice(2).filter((a) => a !== "--daemon" && a !== "-d"),
|
|
377
|
+
"--daemon-child",
|
|
378
|
+
],
|
|
379
|
+
{
|
|
380
|
+
detached: true,
|
|
381
|
+
stdio: "ignore",
|
|
382
|
+
windowsHide: process.platform === "win32",
|
|
383
|
+
env: { ...process.env, CODEX_MONITOR_DAEMON: "1" },
|
|
384
|
+
cwd: process.cwd(),
|
|
385
|
+
},
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
child.unref();
|
|
389
|
+
writePidFile(child.pid);
|
|
390
|
+
|
|
391
|
+
console.log(`
|
|
392
|
+
╭──────────────────────────────────────────────────────────╮
|
|
393
|
+
│ openfleet daemon started (PID ${String(child.pid).padEnd(24)}│
|
|
394
|
+
╰──────────────────────────────────────────────────────────╯
|
|
395
|
+
|
|
396
|
+
Logs: ${DAEMON_LOG}
|
|
397
|
+
PID: ${PID_FILE}
|
|
398
|
+
|
|
399
|
+
Commands:
|
|
400
|
+
openfleet --daemon-status Check if running
|
|
401
|
+
openfleet --stop-daemon Stop the daemon
|
|
402
|
+
`);
|
|
403
|
+
process.exit(0);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function stopDaemon() {
|
|
407
|
+
const pid = getDaemonPid();
|
|
408
|
+
if (!pid) {
|
|
409
|
+
console.log(" No daemon running (PID file not found or process dead).");
|
|
410
|
+
removePidFile();
|
|
411
|
+
process.exit(0);
|
|
412
|
+
}
|
|
413
|
+
console.log(` Stopping openfleet daemon (PID ${pid})...`);
|
|
414
|
+
try {
|
|
415
|
+
process.kill(pid, "SIGTERM");
|
|
416
|
+
// Wait briefly for graceful shutdown
|
|
417
|
+
let tries = 0;
|
|
418
|
+
const check = () => {
|
|
419
|
+
try {
|
|
420
|
+
process.kill(pid, 0);
|
|
421
|
+
} catch {
|
|
422
|
+
removePidFile();
|
|
423
|
+
console.log(" ✓ Daemon stopped.");
|
|
424
|
+
process.exit(0);
|
|
425
|
+
}
|
|
426
|
+
if (++tries > 10) {
|
|
427
|
+
console.log(" Sending SIGKILL...");
|
|
428
|
+
try {
|
|
429
|
+
process.kill(pid, "SIGKILL");
|
|
430
|
+
} catch {
|
|
431
|
+
/* ok */
|
|
432
|
+
}
|
|
433
|
+
removePidFile();
|
|
434
|
+
console.log(" ✓ Daemon killed.");
|
|
435
|
+
process.exit(0);
|
|
436
|
+
}
|
|
437
|
+
setTimeout(check, 500);
|
|
438
|
+
};
|
|
439
|
+
setTimeout(check, 500);
|
|
440
|
+
} catch (err) {
|
|
441
|
+
console.error(` Failed to stop daemon: ${err.message}`);
|
|
442
|
+
removePidFile();
|
|
443
|
+
process.exit(1);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function daemonStatus() {
|
|
448
|
+
const pid = getDaemonPid();
|
|
449
|
+
if (pid) {
|
|
450
|
+
console.log(` openfleet daemon is running (PID ${pid})`);
|
|
451
|
+
} else {
|
|
452
|
+
console.log(" openfleet daemon is not running.");
|
|
453
|
+
removePidFile();
|
|
454
|
+
}
|
|
455
|
+
process.exit(0);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function main() {
|
|
459
|
+
// Handle --help
|
|
460
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
461
|
+
showHelp();
|
|
462
|
+
process.exit(0);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Handle --version
|
|
466
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
467
|
+
console.log(`openfleet v${VERSION}`);
|
|
468
|
+
process.exit(0);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Handle --doctor
|
|
472
|
+
if (args.includes("--doctor") || args.includes("doctor")) {
|
|
473
|
+
const { runConfigDoctor, formatConfigDoctorReport } =
|
|
474
|
+
await import("./config-doctor.mjs");
|
|
475
|
+
const result = runConfigDoctor();
|
|
476
|
+
console.log(formatConfigDoctorReport(result));
|
|
477
|
+
process.exit(result.ok ? 0 : 1);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Handle sentinel controls
|
|
481
|
+
if (args.includes("--sentinel-stop")) {
|
|
482
|
+
process.exit(await runSentinelCli("--stop"));
|
|
483
|
+
}
|
|
484
|
+
if (args.includes("--sentinel-status")) {
|
|
485
|
+
process.exit(await runSentinelCli("--status"));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Handle --daemon
|
|
489
|
+
if (args.includes("--daemon") || args.includes("-d")) {
|
|
490
|
+
const { shouldRunSetup, runSetup } = await import("./setup.mjs");
|
|
491
|
+
if (shouldRunSetup()) {
|
|
492
|
+
console.log(
|
|
493
|
+
"\n 🚀 First run detected — setup is required before daemon mode.\n",
|
|
494
|
+
);
|
|
495
|
+
await runSetup();
|
|
496
|
+
console.log("\n Setup complete. Starting daemon...\n");
|
|
497
|
+
}
|
|
498
|
+
startDaemon();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (args.includes("--stop-daemon")) {
|
|
502
|
+
stopDaemon();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (args.includes("--daemon-status")) {
|
|
506
|
+
daemonStatus();
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Write PID file if running as daemon child
|
|
511
|
+
if (
|
|
512
|
+
args.includes("--daemon-child") ||
|
|
513
|
+
process.env.CODEX_MONITOR_DAEMON === "1"
|
|
514
|
+
) {
|
|
515
|
+
writePidFile(process.pid);
|
|
516
|
+
// Redirect console to log file on daemon child
|
|
517
|
+
const { createWriteStream } = await import("node:fs");
|
|
518
|
+
const logStream = createWriteStream(DAEMON_LOG, { flags: "a" });
|
|
519
|
+
let logStreamErrored = false;
|
|
520
|
+
logStream.on("error", () => {
|
|
521
|
+
logStreamErrored = true;
|
|
522
|
+
});
|
|
523
|
+
const origStdout = process.stdout.write.bind(process.stdout);
|
|
524
|
+
const origStderr = process.stderr.write.bind(process.stderr);
|
|
525
|
+
const safeWrite = (writeFn, chunk, args) => {
|
|
526
|
+
try {
|
|
527
|
+
return writeFn(chunk, ...args);
|
|
528
|
+
} catch (err) {
|
|
529
|
+
if (
|
|
530
|
+
err &&
|
|
531
|
+
(err.code === "EPIPE" ||
|
|
532
|
+
err.code === "ERR_STREAM_DESTROYED" ||
|
|
533
|
+
err.code === "ERR_STREAM_WRITE_AFTER_END")
|
|
534
|
+
) {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
throw err;
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
process.stdout.write = (chunk, ...a) => {
|
|
541
|
+
if (!logStreamErrored) {
|
|
542
|
+
safeWrite(logStream.write.bind(logStream), chunk, []);
|
|
543
|
+
}
|
|
544
|
+
return safeWrite(origStdout, chunk, a);
|
|
545
|
+
};
|
|
546
|
+
process.stderr.write = (chunk, ...a) => {
|
|
547
|
+
if (!logStreamErrored) {
|
|
548
|
+
safeWrite(logStream.write.bind(logStream), chunk, []);
|
|
549
|
+
}
|
|
550
|
+
return safeWrite(origStderr, chunk, a);
|
|
551
|
+
};
|
|
552
|
+
console.log(
|
|
553
|
+
`\n[daemon] openfleet started at ${new Date().toISOString()} (PID ${process.pid})`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const sentinelRequested =
|
|
558
|
+
args.includes("--sentinel") ||
|
|
559
|
+
parseBoolEnv(process.env.CODEX_MONITOR_SENTINEL_AUTO_START, false);
|
|
560
|
+
if (sentinelRequested) {
|
|
561
|
+
const sentinel = await ensureSentinelRunning({ quiet: false });
|
|
562
|
+
if (!sentinel.ok) {
|
|
563
|
+
const mode = args.includes("--sentinel")
|
|
564
|
+
? "requested by --sentinel"
|
|
565
|
+
: "requested by CODEX_MONITOR_SENTINEL_AUTO_START";
|
|
566
|
+
const strictSentinel = parseBoolEnv(
|
|
567
|
+
process.env.CODEX_MONITOR_SENTINEL_STRICT,
|
|
568
|
+
false,
|
|
569
|
+
);
|
|
570
|
+
const prefix = strictSentinel ? "✖" : "⚠";
|
|
571
|
+
const suffix = strictSentinel
|
|
572
|
+
? ""
|
|
573
|
+
: " (continuing without sentinel companion)";
|
|
574
|
+
console.error(
|
|
575
|
+
` ${prefix} Failed to start telegram-sentinel (${mode}): ${sentinel.error}${suffix}`,
|
|
576
|
+
);
|
|
577
|
+
if (strictSentinel) {
|
|
578
|
+
process.exit(1);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Handle --enable-startup / --disable-startup / --startup-status
|
|
584
|
+
if (args.includes("--enable-startup")) {
|
|
585
|
+
const { installStartupService, getStartupMethodName } =
|
|
586
|
+
await import("./startup-service.mjs");
|
|
587
|
+
const result = await installStartupService({ daemon: true });
|
|
588
|
+
if (result.success) {
|
|
589
|
+
console.log(` \u2705 Startup service installed via ${result.method}`);
|
|
590
|
+
if (result.path) console.log(` Path: ${result.path}`);
|
|
591
|
+
if (result.name) console.log(` Name: ${result.name}`);
|
|
592
|
+
console.log(`\n openfleet will auto-start on login.`);
|
|
593
|
+
} else {
|
|
594
|
+
console.error(
|
|
595
|
+
` \u274c Failed to install startup service: ${result.error}`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
process.exit(result.success ? 0 : 1);
|
|
599
|
+
}
|
|
600
|
+
if (args.includes("--disable-startup")) {
|
|
601
|
+
const { removeStartupService } = await import("./startup-service.mjs");
|
|
602
|
+
const result = await removeStartupService();
|
|
603
|
+
if (result.success) {
|
|
604
|
+
console.log(` \u2705 Startup service removed (${result.method})`);
|
|
605
|
+
} else {
|
|
606
|
+
console.error(
|
|
607
|
+
` \u274c Failed to remove startup service: ${result.error}`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
process.exit(result.success ? 0 : 1);
|
|
611
|
+
}
|
|
612
|
+
if (args.includes("--startup-status")) {
|
|
613
|
+
const { getStartupStatus } = await import("./startup-service.mjs");
|
|
614
|
+
const status = getStartupStatus();
|
|
615
|
+
if (status.installed) {
|
|
616
|
+
console.log(` Startup service: installed (${status.method})`);
|
|
617
|
+
if (status.name) console.log(` Name: ${status.name}`);
|
|
618
|
+
if (status.path) console.log(` Path: ${status.path}`);
|
|
619
|
+
if (status.running !== undefined)
|
|
620
|
+
console.log(` Running: ${status.running ? "yes" : "no"}`);
|
|
621
|
+
} else {
|
|
622
|
+
console.log(` Startup service: not installed`);
|
|
623
|
+
console.log(` Run 'openfleet --enable-startup' to register.`);
|
|
624
|
+
}
|
|
625
|
+
process.exit(0);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Handle --update (force update)
|
|
629
|
+
if (args.includes("--update")) {
|
|
630
|
+
const { forceUpdate } = await import("./update-check.mjs");
|
|
631
|
+
await forceUpdate(VERSION);
|
|
632
|
+
process.exit(0);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ── Startup banner with update check ──────────────────────────────────────
|
|
636
|
+
console.log("");
|
|
637
|
+
console.log(" ╭──────────────────────────────────────────────────────────╮");
|
|
638
|
+
console.log(
|
|
639
|
+
` │ >_ openfleet (v${VERSION})${" ".repeat(Math.max(0, 39 - VERSION.length))}│`,
|
|
640
|
+
);
|
|
641
|
+
console.log(" ╰──────────────────────────────────────────────────────────╯");
|
|
642
|
+
|
|
643
|
+
// Non-blocking update check (don't delay startup)
|
|
644
|
+
if (!args.includes("--no-update-check")) {
|
|
645
|
+
import("./update-check.mjs")
|
|
646
|
+
.then(({ checkForUpdate }) => checkForUpdate(VERSION))
|
|
647
|
+
.catch(() => {}); // silent — never block startup
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Propagate --no-auto-update to env for monitor.mjs to pick up
|
|
651
|
+
if (args.includes("--no-auto-update")) {
|
|
652
|
+
process.env.CODEX_MONITOR_SKIP_AUTO_UPDATE = "1";
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Mark all child processes as openfleet managed.
|
|
656
|
+
// The agent-hook-bridge checks this to avoid firing hooks for standalone
|
|
657
|
+
// agent sessions that happen to have hook config files in their tree.
|
|
658
|
+
process.env.VE_MANAGED = "1";
|
|
659
|
+
|
|
660
|
+
// Handle --setup
|
|
661
|
+
if (args.includes("--setup") || args.includes("setup")) {
|
|
662
|
+
const configDirArg = getArgValue("--config-dir");
|
|
663
|
+
if (configDirArg) {
|
|
664
|
+
process.env.CODEX_MONITOR_DIR = configDirArg;
|
|
665
|
+
}
|
|
666
|
+
const { runSetup } = await import("./setup.mjs");
|
|
667
|
+
await runSetup();
|
|
668
|
+
process.exit(0);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Handle --whatsapp-auth
|
|
672
|
+
if (args.includes("--whatsapp-auth") || args.includes("whatsapp-auth")) {
|
|
673
|
+
const mode = args.includes("--pairing-code") ? "pairing-code" : "qr";
|
|
674
|
+
const { runWhatsAppAuth } = await import("./whatsapp-channel.mjs");
|
|
675
|
+
await runWhatsAppAuth(mode);
|
|
676
|
+
process.exit(0);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// First-run detection
|
|
680
|
+
const { shouldRunSetup } = await import("./setup.mjs");
|
|
681
|
+
if (shouldRunSetup()) {
|
|
682
|
+
console.log("\n 🚀 First run detected — launching setup wizard...\n");
|
|
683
|
+
const configDirArg = getArgValue("--config-dir");
|
|
684
|
+
if (configDirArg) {
|
|
685
|
+
process.env.CODEX_MONITOR_DIR = configDirArg;
|
|
686
|
+
}
|
|
687
|
+
const { runSetup } = await import("./setup.mjs");
|
|
688
|
+
await runSetup();
|
|
689
|
+
console.log("\n Setup complete! Starting openfleet...\n");
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Fork monitor as a child process — enables self-restart on source changes.
|
|
693
|
+
// When monitor exits with code 75, cli re-forks with a fresh ESM module cache.
|
|
694
|
+
await runMonitor();
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ── Crash notification (last resort — raw fetch when monitor can't start) ─────
|
|
698
|
+
|
|
699
|
+
function readEnvCredentials() {
|
|
700
|
+
const envPath = resolve(__dirname, ".env");
|
|
701
|
+
if (!existsSync(envPath)) return {};
|
|
702
|
+
const vars = {};
|
|
703
|
+
try {
|
|
704
|
+
const lines = readFileSync(envPath, "utf8").split("\n");
|
|
705
|
+
for (const line of lines) {
|
|
706
|
+
const trimmed = line.trim();
|
|
707
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
708
|
+
const eqIdx = trimmed.indexOf("=");
|
|
709
|
+
if (eqIdx === -1) continue;
|
|
710
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
711
|
+
let val = trimmed.slice(eqIdx + 1).trim();
|
|
712
|
+
if (
|
|
713
|
+
(val.startsWith('"') && val.endsWith('"')) ||
|
|
714
|
+
(val.startsWith("'") && val.endsWith("'"))
|
|
715
|
+
) {
|
|
716
|
+
val = val.slice(1, -1);
|
|
717
|
+
}
|
|
718
|
+
if (
|
|
719
|
+
key === "TELEGRAM_BOT_TOKEN" ||
|
|
720
|
+
key === "TELEGRAM_CHAT_ID" ||
|
|
721
|
+
key === "PROJECT_NAME"
|
|
722
|
+
) {
|
|
723
|
+
vars[key] = val;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
} catch {
|
|
727
|
+
// best effort
|
|
728
|
+
}
|
|
729
|
+
return vars;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function sendCrashNotification(exitCode, signal, options = {}) {
|
|
733
|
+
const { autoRestartInMs = 0, restartAttempt = 0, maxRestarts = 0 } = options;
|
|
734
|
+
const env = readEnvCredentials();
|
|
735
|
+
const token = env.TELEGRAM_BOT_TOKEN || process.env.TELEGRAM_BOT_TOKEN;
|
|
736
|
+
const chatId = env.TELEGRAM_CHAT_ID || process.env.TELEGRAM_CHAT_ID;
|
|
737
|
+
if (!token || !chatId) return;
|
|
738
|
+
|
|
739
|
+
const project = env.PROJECT_NAME || process.env.PROJECT_NAME || "";
|
|
740
|
+
const host = os.hostname();
|
|
741
|
+
const tag = project ? `[${project}]` : "";
|
|
742
|
+
const reason = signal ? `signal ${signal}` : `exit code ${exitCode}`;
|
|
743
|
+
const isAutoRestart = Number(autoRestartInMs) > 0;
|
|
744
|
+
const restartLine = isAutoRestart
|
|
745
|
+
? [
|
|
746
|
+
`Auto-restart scheduled in ${Math.max(1, Math.round(autoRestartInMs / 1000))}s.`,
|
|
747
|
+
restartAttempt > 0
|
|
748
|
+
? `Restart attempt: ${restartAttempt}${maxRestarts > 0 ? `/${maxRestarts}` : ""}`
|
|
749
|
+
: "",
|
|
750
|
+
]
|
|
751
|
+
.filter(Boolean)
|
|
752
|
+
.join("\n")
|
|
753
|
+
: "Monitor is no longer running. Manual restart required.";
|
|
754
|
+
const text =
|
|
755
|
+
`🔥 *CRASH* ${tag} openfleet v${VERSION} died unexpectedly\n` +
|
|
756
|
+
`Host: \`${host}\`\n` +
|
|
757
|
+
`Reason: \`${reason}\`\n` +
|
|
758
|
+
`Time: ${new Date().toISOString()}\n\n` +
|
|
759
|
+
restartLine;
|
|
760
|
+
|
|
761
|
+
const url = `https://api.telegram.org/bot${token}/sendMessage`;
|
|
762
|
+
try {
|
|
763
|
+
await fetch(url, {
|
|
764
|
+
method: "POST",
|
|
765
|
+
headers: { "Content-Type": "application/json" },
|
|
766
|
+
body: JSON.stringify({
|
|
767
|
+
chat_id: chatId,
|
|
768
|
+
text,
|
|
769
|
+
parse_mode: "Markdown",
|
|
770
|
+
}),
|
|
771
|
+
signal: AbortSignal.timeout(10_000),
|
|
772
|
+
});
|
|
773
|
+
} catch {
|
|
774
|
+
// best effort — if Telegram is unreachable, nothing we can do
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ── Self-restart exit code (must match monitor.mjs SELF_RESTART_EXIT_CODE) ───
|
|
779
|
+
const SELF_RESTART_EXIT_CODE = 75;
|
|
780
|
+
let monitorChild = null;
|
|
781
|
+
|
|
782
|
+
function runMonitor() {
|
|
783
|
+
return new Promise((resolve, reject) => {
|
|
784
|
+
const monitorPath = fileURLToPath(
|
|
785
|
+
new URL("./monitor.mjs", import.meta.url),
|
|
786
|
+
);
|
|
787
|
+
monitorChild = fork(monitorPath, process.argv.slice(2), {
|
|
788
|
+
stdio: "inherit",
|
|
789
|
+
execArgv: ["--max-old-space-size=4096"],
|
|
790
|
+
windowsHide: IS_DAEMON_CHILD && process.platform === "win32",
|
|
791
|
+
});
|
|
792
|
+
daemonCrashTracker.markStart();
|
|
793
|
+
|
|
794
|
+
monitorChild.on("exit", (code, signal) => {
|
|
795
|
+
monitorChild = null;
|
|
796
|
+
if (code === SELF_RESTART_EXIT_CODE) {
|
|
797
|
+
console.log(
|
|
798
|
+
"\n \u21BB Monitor source changed \u2014 restarting with fresh modules...\n",
|
|
799
|
+
);
|
|
800
|
+
// Small delay to let file writes settle
|
|
801
|
+
setTimeout(() => resolve(runMonitor()), 1000);
|
|
802
|
+
} else {
|
|
803
|
+
const exitCode = code ?? (signal ? 1 : 0);
|
|
804
|
+
// 4294967295 (0xFFFFFFFF / -1 signed) = OS killed the process (OOM, external termination)
|
|
805
|
+
const isOSKill = exitCode === 4294967295 || exitCode === -1;
|
|
806
|
+
const shouldAutoRestart =
|
|
807
|
+
!gracefulShutdown &&
|
|
808
|
+
(isOSKill || (IS_DAEMON_CHILD && exitCode !== 0));
|
|
809
|
+
if (shouldAutoRestart) {
|
|
810
|
+
const crashState = daemonCrashTracker.recordExit();
|
|
811
|
+
daemonRestartCount += 1;
|
|
812
|
+
const delayMs = isOSKill ? 5000 : DAEMON_RESTART_DELAY_MS;
|
|
813
|
+
if (IS_DAEMON_CHILD && crashState.exceeded) {
|
|
814
|
+
const durationSec = Math.max(
|
|
815
|
+
1,
|
|
816
|
+
Math.round(crashState.runDurationMs / 1000),
|
|
817
|
+
);
|
|
818
|
+
const windowSec = Math.max(
|
|
819
|
+
1,
|
|
820
|
+
Math.round(crashState.instantCrashWindowMs / 1000),
|
|
821
|
+
);
|
|
822
|
+
console.error(
|
|
823
|
+
`\n ✖ Monitor crashed too quickly ${crashState.instantCrashCount} times in a row (each <= ${windowSec}s, latest ${durationSec}s). Auto-restart is now paused.`,
|
|
824
|
+
);
|
|
825
|
+
sendCrashNotification(exitCode, signal).finally(() =>
|
|
826
|
+
process.exit(exitCode),
|
|
827
|
+
);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (
|
|
831
|
+
IS_DAEMON_CHILD &&
|
|
832
|
+
DAEMON_MAX_RESTARTS > 0 &&
|
|
833
|
+
daemonRestartCount > DAEMON_MAX_RESTARTS
|
|
834
|
+
) {
|
|
835
|
+
console.error(
|
|
836
|
+
`\n ✖ Monitor crashed too many times (${daemonRestartCount - 1} restarts, max ${DAEMON_MAX_RESTARTS}).`,
|
|
837
|
+
);
|
|
838
|
+
sendCrashNotification(exitCode, signal).finally(() =>
|
|
839
|
+
process.exit(exitCode),
|
|
840
|
+
);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const reasonLabel = signal
|
|
844
|
+
? `signal ${signal}`
|
|
845
|
+
: `exit code ${exitCode}`;
|
|
846
|
+
const attemptLabel =
|
|
847
|
+
IS_DAEMON_CHILD && DAEMON_MAX_RESTARTS > 0
|
|
848
|
+
? `${daemonRestartCount}/${DAEMON_MAX_RESTARTS}`
|
|
849
|
+
: `${daemonRestartCount}`;
|
|
850
|
+
console.error(
|
|
851
|
+
`\n ⚠ Monitor exited (${reasonLabel}) — auto-restarting in ${Math.max(1, Math.round(delayMs / 1000))}s${IS_DAEMON_CHILD ? ` [attempt ${attemptLabel}]` : ""}...`,
|
|
852
|
+
);
|
|
853
|
+
sendCrashNotification(exitCode, signal, {
|
|
854
|
+
autoRestartInMs: delayMs,
|
|
855
|
+
restartAttempt: daemonRestartCount,
|
|
856
|
+
maxRestarts: IS_DAEMON_CHILD ? DAEMON_MAX_RESTARTS : 0,
|
|
857
|
+
}).catch(() => {});
|
|
858
|
+
setTimeout(() => resolve(runMonitor()), delayMs);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (exitCode !== 0 && !gracefulShutdown) {
|
|
863
|
+
console.error(
|
|
864
|
+
`\n ✖ Monitor crashed (${signal ? `signal ${signal}` : `exit code ${exitCode}`}) — sending crash notification...`,
|
|
865
|
+
);
|
|
866
|
+
sendCrashNotification(exitCode, signal).finally(() =>
|
|
867
|
+
process.exit(exitCode),
|
|
868
|
+
);
|
|
869
|
+
} else {
|
|
870
|
+
daemonRestartCount = 0;
|
|
871
|
+
daemonCrashTracker.reset();
|
|
872
|
+
process.exit(exitCode);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
monitorChild.on("error", (err) => {
|
|
878
|
+
monitorChild = null;
|
|
879
|
+
console.error(`\n ✖ Monitor failed to start: ${err.message}`);
|
|
880
|
+
sendCrashNotification(1, null).finally(() => reject(err));
|
|
881
|
+
});
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Let forked monitor handle signal cleanup — prevent parent from dying first
|
|
886
|
+
let gracefulShutdown = false;
|
|
887
|
+
process.on("SIGINT", () => {
|
|
888
|
+
gracefulShutdown = true;
|
|
889
|
+
if (!monitorChild) process.exit(0);
|
|
890
|
+
// Child gets SIGINT too via shared terminal — just wait for it to exit
|
|
891
|
+
});
|
|
892
|
+
process.on("SIGTERM", () => {
|
|
893
|
+
gracefulShutdown = true;
|
|
894
|
+
if (!monitorChild) process.exit(0);
|
|
895
|
+
try {
|
|
896
|
+
monitorChild.kill("SIGTERM");
|
|
897
|
+
} catch {
|
|
898
|
+
/* best effort */
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
main().catch(async (err) => {
|
|
903
|
+
console.error(`openfleet failed: ${err.message}`);
|
|
904
|
+
await sendCrashNotification(1, null).catch(() => {});
|
|
905
|
+
process.exit(1);
|
|
906
|
+
});
|