@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.
Files changed (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. 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
+ });