@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
@@ -0,0 +1,1070 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * openfleet — Startup Service Manager
5
+ *
6
+ * Cross-platform registration for auto-starting openfleet on login/boot.
7
+ * Supports:
8
+ * - Windows: Task Scheduler (schtasks)
9
+ * - macOS: launchd (~/Library/LaunchAgents)
10
+ * - Linux: systemd user units (~/.config/systemd/user)
11
+ *
12
+ * Usage:
13
+ * import { installStartupService, removeStartupService, getStartupStatus } from './startup-service.mjs';
14
+ *
15
+ * await installStartupService({ daemon: true }); // Install with --daemon flag
16
+ * await removeStartupService(); // Uninstall
17
+ * getStartupStatus(); // Check current state
18
+ */
19
+
20
+ import { execSync, spawnSync } from "node:child_process";
21
+ import {
22
+ existsSync,
23
+ readFileSync,
24
+ writeFileSync,
25
+ mkdirSync,
26
+ unlinkSync,
27
+ } from "node:fs";
28
+ import { resolve, dirname, basename } from "node:path";
29
+ import { homedir } from "node:os";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ const __dirname = dirname(fileURLToPath(import.meta.url));
33
+
34
+ const SERVICE_LABEL = "com.openfleet.service";
35
+ const TASK_NAME = "CodexMonitor";
36
+ const SYSTEMD_UNIT = "openfleet.service";
37
+
38
+ // ── Platform Detection ───────────────────────────────────────────────────────
39
+
40
+ function getPlatform() {
41
+ switch (process.platform) {
42
+ case "win32":
43
+ return "windows";
44
+ case "darwin":
45
+ return "macos";
46
+ case "linux":
47
+ return "linux";
48
+ default:
49
+ return "unsupported";
50
+ }
51
+ }
52
+
53
+ // ── Path Helpers ─────────────────────────────────────────────────────────────
54
+
55
+ function getNodePath() {
56
+ return process.execPath;
57
+ }
58
+
59
+ function getCliPath() {
60
+ return resolve(__dirname, "cli.mjs");
61
+ }
62
+
63
+ function getWorkingDirectory() {
64
+ return __dirname;
65
+ }
66
+
67
+ function getLogDir() {
68
+ const dir = resolve(__dirname, "logs");
69
+ mkdirSync(dir, { recursive: true });
70
+ return dir;
71
+ }
72
+
73
+ // ── Windows: Task Scheduler ──────────────────────────────────────────────────
74
+
75
+ /**
76
+ * Check whether the current process is running elevated (admin).
77
+ */
78
+ function isElevated() {
79
+ try {
80
+ // net session succeeds only as admin
81
+ execSync("net session", { stdio: "ignore" });
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Get the Startup folder shortcut path (per-user, never needs admin).
90
+ */
91
+ function getStartupShortcutPath() {
92
+ const startupDir = resolve(
93
+ homedir(),
94
+ "AppData",
95
+ "Roaming",
96
+ "Microsoft",
97
+ "Windows",
98
+ "Start Menu",
99
+ "Programs",
100
+ "Startup",
101
+ );
102
+ return resolve(startupDir, `${TASK_NAME}.vbs`);
103
+ }
104
+
105
+ /**
106
+ * Build the command string for launching openfleet.
107
+ */
108
+ function buildLaunchCommand({ daemon = true } = {}) {
109
+ const nodePath = getNodePath();
110
+ const cliPath = getCliPath();
111
+ const daemonFlag = daemon ? " --daemon" : "";
112
+ return `"${nodePath}" "${cliPath}"${daemonFlag}`;
113
+ }
114
+
115
+ function generateTaskSchedulerXml({ daemon = true } = {}) {
116
+ const nodePath = getNodePath();
117
+ const cliPath = getCliPath();
118
+ const args = daemon ? `"${cliPath}" --daemon` : `"${cliPath}"`;
119
+
120
+ return `<?xml version="1.0" encoding="UTF-16"?>
121
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
122
+ <RegistrationInfo>
123
+ <Description>Auto-start openfleet AI orchestrator on login</Description>
124
+ <Author>Codex Monitor</Author>
125
+ <URI>\\${TASK_NAME}</URI>
126
+ </RegistrationInfo>
127
+ <Triggers>
128
+ <LogonTrigger>
129
+ <Enabled>true</Enabled>
130
+ </LogonTrigger>
131
+ </Triggers>
132
+ <Principals>
133
+ <Principal id="Author">
134
+ <LogonType>InteractiveToken</LogonType>
135
+ <RunLevel>LeastPrivilege</RunLevel>
136
+ </Principal>
137
+ </Principals>
138
+ <Settings>
139
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
140
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
141
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
142
+ <AllowHardTerminate>true</AllowHardTerminate>
143
+ <StartWhenAvailable>true</StartWhenAvailable>
144
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
145
+ <AllowStartOnDemand>true</AllowStartOnDemand>
146
+ <Enabled>true</Enabled>
147
+ <Hidden>true</Hidden>
148
+ <RunOnlyIfIdle>false</RunOnlyIfIdle>
149
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
150
+ <Priority>7</Priority>
151
+ <RestartOnFailure>
152
+ <Interval>PT5M</Interval>
153
+ <Count>3</Count>
154
+ </RestartOnFailure>
155
+ </Settings>
156
+ <Actions Context="Author">
157
+ <Exec>
158
+ <Command>"${nodePath}"</Command>
159
+ <Arguments>${args}</Arguments>
160
+ <WorkingDirectory>${getWorkingDirectory()}</WorkingDirectory>
161
+ </Exec>
162
+ </Actions>
163
+ </Task>`;
164
+ }
165
+
166
+ /**
167
+ * Attempt to run a schtasks command with UAC elevation via PowerShell.
168
+ * Spawns an elevated PowerShell window and waits for completion.
169
+ * @param {string} schtasksArgs - The schtasks arguments (e.g. '/Create /TN ...')
170
+ * @returns {{ success: boolean, error?: string }}
171
+ */
172
+ function runElevated(schtasksArgs) {
173
+ // Build a PowerShell script that runs schtasks elevated and signals result
174
+ const resultFile = resolve(__dirname, ".cache", "elevation-result.txt");
175
+ try {
176
+ if (existsSync(resultFile)) unlinkSync(resultFile);
177
+ } catch {
178
+ /* ok */
179
+ }
180
+
181
+ // The inner script: run schtasks, write exit code to result file
182
+ const innerScript = `
183
+ try {
184
+ $output = & schtasks ${schtasksArgs} 2>&1;
185
+ $output | Out-String | Set-Content -Path '${resultFile.replace(/\\/g, "\\\\")}' -Encoding UTF8;
186
+ if ($LASTEXITCODE -ne 0) { Add-Content -Path '${resultFile.replace(/\\/g, "\\\\")}' -Value "EXIT:$LASTEXITCODE" }
187
+ else { Add-Content -Path '${resultFile.replace(/\\/g, "\\\\")}' -Value "EXIT:0" }
188
+ } catch {
189
+ "ERROR: $($_.Exception.Message)" | Set-Content -Path '${resultFile.replace(/\\/g, "\\\\")}' -Encoding UTF8;
190
+ Add-Content -Path '${resultFile.replace(/\\/g, "\\\\")}' -Value "EXIT:1"
191
+ }
192
+ `.trim();
193
+
194
+ // Encode the script as base64 for -EncodedCommand
195
+ const encoded = Buffer.from(innerScript, "utf16le").toString("base64");
196
+
197
+ // Launch elevated PowerShell with -Verb RunAs (this triggers the UAC prompt)
198
+ const result = spawnSync(
199
+ "powershell.exe",
200
+ [
201
+ "-NoProfile",
202
+ "-Command",
203
+ `Start-Process powershell.exe -ArgumentList '-NoProfile','-NonInteractive','-EncodedCommand','${encoded}' -Verb RunAs -Wait`,
204
+ ],
205
+ {
206
+ stdio: "pipe",
207
+ timeout: 60000, // 60s — UAC can take time
208
+ windowsHide: false, // Must show the UAC dialog
209
+ },
210
+ );
211
+
212
+ // Read result file
213
+ try {
214
+ if (existsSync(resultFile)) {
215
+ const content = readFileSync(resultFile, "utf8").trim();
216
+ unlinkSync(resultFile);
217
+ const exitMatch = content.match(/EXIT:(\d+)/);
218
+ const exitCode = exitMatch ? parseInt(exitMatch[1], 10) : 1;
219
+ if (exitCode === 0) {
220
+ return { success: true };
221
+ }
222
+ const errorMsg =
223
+ content.replace(/EXIT:\d+/, "").trim() || "Elevated command failed";
224
+ return { success: false, error: errorMsg };
225
+ }
226
+ } catch {
227
+ /* fall through */
228
+ }
229
+
230
+ // No result file — user may have cancelled UAC
231
+ if (result.status !== 0 || result.error) {
232
+ return {
233
+ success: false,
234
+ error: result.error?.message || "UAC elevation was cancelled or failed",
235
+ };
236
+ }
237
+ return {
238
+ success: false,
239
+ error: "Elevation result unknown — UAC may have been cancelled",
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Install via Windows Startup folder (VBS wrapper). Never needs admin.
245
+ * Creates a small VBScript that launches node with openfleet.
246
+ */
247
+ function installStartupFolder(options = {}) {
248
+ const shortcutPath = getStartupShortcutPath();
249
+ const launchCmd = buildLaunchCommand(options);
250
+
251
+ // VBScript wrapper to start hidden (no flash console window)
252
+ const vbsContent = `' Auto-generated by openfleet setup
253
+ ' Starts openfleet on login via Startup folder
254
+ Set WshShell = CreateObject("WScript.Shell")
255
+ WshShell.CurrentDirectory = "${getWorkingDirectory().replace(/\\/g, "\\\\")}"
256
+ WshShell.Run ${JSON.stringify(launchCmd)}, 0, False
257
+ `;
258
+
259
+ try {
260
+ mkdirSync(dirname(shortcutPath), { recursive: true });
261
+ writeFileSync(shortcutPath, vbsContent, "utf8");
262
+ return {
263
+ success: true,
264
+ method: "Startup folder",
265
+ name: basename(shortcutPath),
266
+ path: shortcutPath,
267
+ };
268
+ } catch (err) {
269
+ return {
270
+ success: false,
271
+ method: "Startup folder",
272
+ error: err.message,
273
+ };
274
+ }
275
+ }
276
+
277
+ function removeStartupFolder() {
278
+ const shortcutPath = getStartupShortcutPath();
279
+ try {
280
+ if (existsSync(shortcutPath)) {
281
+ unlinkSync(shortcutPath);
282
+ }
283
+ return { success: true, method: "Startup folder" };
284
+ } catch (err) {
285
+ return { success: false, method: "Startup folder", error: err.message };
286
+ }
287
+ }
288
+
289
+ function statusStartupFolder() {
290
+ const shortcutPath = getStartupShortcutPath();
291
+ return {
292
+ installed: existsSync(shortcutPath),
293
+ method: "Startup folder",
294
+ path: shortcutPath,
295
+ };
296
+ }
297
+
298
+ async function installWindows(options = {}) {
299
+ const xmlContent = generateTaskSchedulerXml(options);
300
+ const tmpXml = resolve(__dirname, ".cache", `${TASK_NAME}.xml`);
301
+
302
+ mkdirSync(dirname(tmpXml), { recursive: true });
303
+
304
+ // Write as UTF-16 LE with BOM (required by schtasks)
305
+ const buf = Buffer.from("\ufeff" + xmlContent, "utf16le");
306
+ writeFileSync(tmpXml, buf);
307
+
308
+ // Strategy 1: Try schtasks directly (works if already admin or policy allows)
309
+ try {
310
+ try {
311
+ execSync(`schtasks /Delete /TN "${TASK_NAME}" /F`, { stdio: "ignore" });
312
+ } catch {
313
+ /* ok — task may not exist */
314
+ }
315
+
316
+ execSync(`schtasks /Create /TN "${TASK_NAME}" /XML "${tmpXml}" /F`, {
317
+ stdio: "pipe",
318
+ });
319
+
320
+ try {
321
+ unlinkSync(tmpXml);
322
+ } catch {
323
+ /* ok */
324
+ }
325
+ return { success: true, method: "Task Scheduler", name: TASK_NAME };
326
+ } catch (directErr) {
327
+ const isAccessDenied =
328
+ directErr.message?.includes("Access is denied") ||
329
+ directErr.message?.includes("access is denied") ||
330
+ directErr.status === 1;
331
+
332
+ if (!isAccessDenied) {
333
+ try {
334
+ unlinkSync(tmpXml);
335
+ } catch {
336
+ /* ok */
337
+ }
338
+ return {
339
+ success: false,
340
+ method: "Task Scheduler",
341
+ error: directErr.message,
342
+ };
343
+ }
344
+
345
+ // Strategy 2: Elevate via UAC prompt
346
+ console.log(
347
+ " ℹ️ Admin access required — requesting elevation (UAC prompt)...",
348
+ );
349
+
350
+ // Delete + Create via elevated process
351
+ const deleteArgs = `/Delete /TN "${TASK_NAME}" /F`;
352
+ runElevated(deleteArgs); // ignore errors — may not exist
353
+
354
+ const createArgs = `/Create /TN "${TASK_NAME}" /XML "${tmpXml.replace(/\\/g, "\\\\")}" /F`;
355
+ const elevated = runElevated(createArgs);
356
+
357
+ try {
358
+ unlinkSync(tmpXml);
359
+ } catch {
360
+ /* ok */
361
+ }
362
+
363
+ if (elevated.success) {
364
+ return {
365
+ success: true,
366
+ method: "Task Scheduler (elevated)",
367
+ name: TASK_NAME,
368
+ };
369
+ }
370
+
371
+ // Strategy 3: Fall back to Startup folder (no admin needed)
372
+ console.log(
373
+ " ⚠️ Task Scheduler elevation failed — falling back to Startup folder.",
374
+ );
375
+ console.log(
376
+ " (Startup folder works without admin, but has no auto-restart on failure)",
377
+ );
378
+ return installStartupFolder(options);
379
+ }
380
+ }
381
+
382
+ async function removeWindows() {
383
+ // Remove from both Task Scheduler and Startup folder (whichever exists)
384
+ const results = [];
385
+
386
+ // Try removing scheduled task
387
+ try {
388
+ execSync(`schtasks /Delete /TN "${TASK_NAME}" /F`, { stdio: "pipe" });
389
+ results.push({ success: true, method: "Task Scheduler" });
390
+ } catch (directErr) {
391
+ const isAccessDenied =
392
+ directErr.message?.includes("Access is denied") ||
393
+ directErr.message?.includes("access is denied");
394
+
395
+ if (isAccessDenied) {
396
+ console.log(
397
+ " ℹ️ Admin access required — requesting elevation (UAC prompt)...",
398
+ );
399
+ const elevated = runElevated(`/Delete /TN "${TASK_NAME}" /F`);
400
+ results.push({
401
+ success: elevated.success,
402
+ method: "Task Scheduler (elevated)",
403
+ error: elevated.success ? undefined : elevated.error,
404
+ });
405
+ } else {
406
+ // Task may simply not exist — that's fine
407
+ results.push({ success: true, method: "Task Scheduler" });
408
+ }
409
+ }
410
+
411
+ // Also remove startup folder shortcut if present
412
+ const shortcutResult = removeStartupFolder();
413
+ if (
414
+ shortcutResult.success &&
415
+ existsSync(getStartupShortcutPath()) === false
416
+ ) {
417
+ results.push(shortcutResult);
418
+ }
419
+
420
+ // Return combined result
421
+ const anySuccess = results.some((r) => r.success);
422
+ return {
423
+ success: anySuccess,
424
+ method: results.map((r) => r.method).join(" + "),
425
+ };
426
+ }
427
+
428
+ function statusWindows() {
429
+ // Check Task Scheduler first
430
+ try {
431
+ const output = execSync(`schtasks /Query /TN "${TASK_NAME}" /FO CSV /NH`, {
432
+ encoding: "utf8",
433
+ stdio: ["pipe", "pipe", "ignore"],
434
+ }).trim();
435
+ if (output && output.includes(TASK_NAME)) {
436
+ const enabled = output.toLowerCase().includes("ready");
437
+ return {
438
+ installed: true,
439
+ enabled,
440
+ method: "Task Scheduler",
441
+ name: TASK_NAME,
442
+ };
443
+ }
444
+ } catch {
445
+ /* not in Task Scheduler — check Startup folder */
446
+ }
447
+
448
+ // Check Startup folder fallback
449
+ const folderStatus = statusStartupFolder();
450
+ if (folderStatus.installed) {
451
+ return { ...folderStatus, enabled: true };
452
+ }
453
+
454
+ return { installed: false, method: "Task Scheduler" };
455
+ }
456
+
457
+ // ── macOS: launchd ───────────────────────────────────────────────────────────
458
+
459
+ function getLaunchdPlistPath() {
460
+ return resolve(
461
+ homedir(),
462
+ "Library",
463
+ "LaunchAgents",
464
+ `${SERVICE_LABEL}.plist`,
465
+ );
466
+ }
467
+
468
+ function generateLaunchdPlist({ daemon = true } = {}) {
469
+ const nodePath = getNodePath();
470
+ const cliPath = getCliPath();
471
+ const logDir = getLogDir();
472
+ const home = homedir();
473
+ const args = daemon ? [nodePath, cliPath, "--daemon"] : [nodePath, cliPath];
474
+
475
+ const argsXml = args.map((a) => ` <string>${a}</string>`).join("\n");
476
+
477
+ return `<?xml version="1.0" encoding="UTF-8"?>
478
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
479
+ <plist version="1.0">
480
+ <dict>
481
+ <key>Label</key>
482
+ <string>${SERVICE_LABEL}</string>
483
+ <key>ProgramArguments</key>
484
+ <array>
485
+ ${argsXml}
486
+ </array>
487
+ <key>WorkingDirectory</key>
488
+ <string>${getWorkingDirectory()}</string>
489
+ <key>RunAtLoad</key>
490
+ <true/>
491
+ <key>KeepAlive</key>
492
+ <dict>
493
+ <key>SuccessfulExit</key>
494
+ <false/>
495
+ </dict>
496
+ <key>ThrottleInterval</key>
497
+ <integer>30</integer>
498
+ <key>EnvironmentVariables</key>
499
+ <dict>
500
+ <key>PATH</key>
501
+ <string>${home}/.local/bin:/usr/local/bin:/usr/bin:/bin</string>
502
+ <key>HOME</key>
503
+ <string>${home}</string>
504
+ </dict>
505
+ <key>StandardOutPath</key>
506
+ <string>${logDir}/startup.log</string>
507
+ <key>StandardErrorPath</key>
508
+ <string>${logDir}/startup.error.log</string>
509
+ </dict>
510
+ </plist>`;
511
+ }
512
+
513
+ async function installMacOS(options = {}) {
514
+ const plistPath = getLaunchdPlistPath();
515
+ const plistContent = generateLaunchdPlist(options);
516
+
517
+ try {
518
+ // Unload existing agent if present
519
+ try {
520
+ execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" });
521
+ } catch {
522
+ /* ok */
523
+ }
524
+
525
+ mkdirSync(dirname(plistPath), { recursive: true });
526
+ writeFileSync(plistPath, plistContent, "utf8");
527
+ execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
528
+
529
+ return {
530
+ success: true,
531
+ method: "launchd",
532
+ name: SERVICE_LABEL,
533
+ path: plistPath,
534
+ };
535
+ } catch (err) {
536
+ const isPermission =
537
+ err.message?.includes("Permission denied") ||
538
+ err.message?.includes("Operation not permitted") ||
539
+ err.message?.includes("EACCES");
540
+
541
+ if (!isPermission) {
542
+ return { success: false, method: "launchd", error: err.message };
543
+ }
544
+
545
+ // Try with sudo — prompts for password in terminal via osascript or direct sudo
546
+ console.log(" ℹ️ Permission required — requesting sudo access...");
547
+ try {
548
+ // Write plist to temp location first
549
+ const tmpPlist = resolve(__dirname, ".cache", `${SERVICE_LABEL}.plist`);
550
+ mkdirSync(dirname(tmpPlist), { recursive: true });
551
+ writeFileSync(tmpPlist, plistContent, "utf8");
552
+
553
+ // Use osascript to prompt for admin credentials (shows macOS auth dialog)
554
+ const escapedPlistPath = plistPath.replace(/'/g, "'\''");
555
+ const escapedTmpPlist = tmpPlist.replace(/'/g, "'\''");
556
+ const script = [
557
+ `do shell script "`,
558
+ `mkdir -p '${dirname(escapedPlistPath)}' && `,
559
+ `cp '${escapedTmpPlist}' '${escapedPlistPath}' && `,
560
+ `launchctl load '${escapedPlistPath}'`,
561
+ `" with administrator privileges`,
562
+ ].join("");
563
+
564
+ execSync(`osascript -e '${script.replace(/'/g, "'\''")}'`, {
565
+ stdio: "pipe",
566
+ timeout: 60000,
567
+ });
568
+
569
+ try {
570
+ unlinkSync(tmpPlist);
571
+ } catch {
572
+ /* ok */
573
+ }
574
+
575
+ return {
576
+ success: true,
577
+ method: "launchd (elevated)",
578
+ name: SERVICE_LABEL,
579
+ path: plistPath,
580
+ };
581
+ } catch (sudoErr) {
582
+ return {
583
+ success: false,
584
+ method: "launchd",
585
+ error:
586
+ `Elevation failed: ${sudoErr.message}. You can install manually:\n` +
587
+ ` sudo cp <plist> ${plistPath}\n` +
588
+ ` launchctl load ${plistPath}`,
589
+ };
590
+ }
591
+ }
592
+ }
593
+
594
+ async function removeMacOS() {
595
+ const plistPath = getLaunchdPlistPath();
596
+ try {
597
+ try {
598
+ execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" });
599
+ } catch {
600
+ /* ok */
601
+ }
602
+ if (existsSync(plistPath)) {
603
+ unlinkSync(plistPath);
604
+ }
605
+ return { success: true, method: "launchd" };
606
+ } catch (err) {
607
+ const isPermission =
608
+ err.message?.includes("Permission denied") ||
609
+ err.message?.includes("Operation not permitted") ||
610
+ err.message?.includes("EACCES");
611
+
612
+ if (!isPermission) {
613
+ return { success: false, method: "launchd", error: err.message };
614
+ }
615
+
616
+ // Elevate via osascript
617
+ console.log(" ℹ️ Permission required — requesting sudo access...");
618
+ try {
619
+ const escapedPlistPath = plistPath.replace(/'/g, "'\\''");
620
+ const script = `do shell script "launchctl unload '${escapedPlistPath}' 2>/dev/null; rm -f '${escapedPlistPath}'" with administrator privileges`;
621
+ execSync(`osascript -e '${script.replace(/'/g, "'\\''")}'`, {
622
+ stdio: "pipe",
623
+ timeout: 60000,
624
+ });
625
+ return { success: true, method: "launchd (elevated)" };
626
+ } catch (sudoErr) {
627
+ return {
628
+ success: false,
629
+ method: "launchd",
630
+ error: `Elevation failed: ${sudoErr.message}`,
631
+ };
632
+ }
633
+ }
634
+ }
635
+
636
+ function statusMacOS() {
637
+ const plistPath = getLaunchdPlistPath();
638
+ if (!existsSync(plistPath)) {
639
+ return { installed: false, method: "launchd" };
640
+ }
641
+ try {
642
+ const output = execSync(`launchctl list`, {
643
+ encoding: "utf8",
644
+ stdio: ["pipe", "pipe", "ignore"],
645
+ });
646
+ const running = output.includes(SERVICE_LABEL);
647
+ return {
648
+ installed: true,
649
+ enabled: true,
650
+ running,
651
+ method: "launchd",
652
+ name: SERVICE_LABEL,
653
+ path: plistPath,
654
+ };
655
+ } catch {
656
+ return {
657
+ installed: true,
658
+ enabled: true,
659
+ method: "launchd",
660
+ path: plistPath,
661
+ };
662
+ }
663
+ }
664
+
665
+ // ── Linux: systemd user unit ─────────────────────────────────────────────────
666
+
667
+ /**
668
+ * Check if systemd user session (--user) is available.
669
+ * Not all Linux environments support it (e.g., WSL1, containers, no logind).
670
+ */
671
+ function hasSystemdUser() {
672
+ try {
673
+ execSync("systemctl --user --no-pager status", {
674
+ stdio: "ignore",
675
+ timeout: 5000,
676
+ });
677
+ return true;
678
+ } catch {
679
+ return false;
680
+ }
681
+ }
682
+
683
+ /**
684
+ * Get the crontab-based fallback marker.
685
+ */
686
+ function getCronMarker() {
687
+ return `# openfleet-autostart`;
688
+ }
689
+
690
+ function getSystemdUnitPath() {
691
+ return resolve(homedir(), ".config", "systemd", "user", SYSTEMD_UNIT);
692
+ }
693
+
694
+ function generateSystemdUnit({ daemon = false } = {}) {
695
+ // systemd handles daemonization — we do NOT use --daemon flag here
696
+ const nodePath = getNodePath();
697
+ const cliPath = getCliPath();
698
+ const logDir = getLogDir();
699
+
700
+ return `[Unit]
701
+ Description=openfleet — AI Orchestrator Supervisor
702
+ Documentation=https://www.npmjs.com/package/@virtengine/openfleet
703
+ After=network-online.target
704
+ Wants=network-online.target
705
+
706
+ [Service]
707
+ Type=simple
708
+ ExecStart=${nodePath} ${cliPath}
709
+ WorkingDirectory=${getWorkingDirectory()}
710
+ Restart=on-failure
711
+ RestartSec=30
712
+ StandardOutput=append:${logDir}/startup.log
713
+ StandardError=append:${logDir}/startup.error.log
714
+ Environment=NODE_ENV=production
715
+ Environment=HOME=${homedir()}
716
+ Environment=PATH=${homedir()}/.local/bin:/usr/local/bin:/usr/bin:/bin
717
+
718
+ [Install]
719
+ WantedBy=default.target
720
+ `;
721
+ }
722
+
723
+ async function installLinux(options = {}) {
724
+ const unitPath = getSystemdUnitPath();
725
+ // systemd handles service lifecycle — never pass --daemon
726
+ const unitContent = generateSystemdUnit({ ...options, daemon: false });
727
+
728
+ // Strategy 1: systemd --user (preferred, no root needed)
729
+ if (hasSystemdUser()) {
730
+ try {
731
+ mkdirSync(dirname(unitPath), { recursive: true });
732
+ writeFileSync(unitPath, unitContent, "utf8");
733
+
734
+ execSync("systemctl --user daemon-reload", { stdio: "pipe" });
735
+ execSync(`systemctl --user enable ${SYSTEMD_UNIT}`, { stdio: "pipe" });
736
+ execSync(`systemctl --user start ${SYSTEMD_UNIT}`, { stdio: "pipe" });
737
+
738
+ return {
739
+ success: true,
740
+ method: "systemd",
741
+ name: SYSTEMD_UNIT,
742
+ path: unitPath,
743
+ };
744
+ } catch (err) {
745
+ const isPermission =
746
+ err.message?.includes("Permission denied") ||
747
+ err.message?.includes("EACCES") ||
748
+ err.message?.includes("Access denied");
749
+
750
+ if (!isPermission) {
751
+ return { success: false, method: "systemd", error: err.message };
752
+ }
753
+
754
+ // Try with sudo for the systemctl commands (unit file is user-space)
755
+ console.log(" ℹ️ Permission required — trying sudo...");
756
+ try {
757
+ // The unit file write doesn't need sudo (it's in ~/.config)
758
+ // but systemctl might if the session isn't fully initialized
759
+ execSync(`sudo systemctl --user daemon-reload`, { stdio: "inherit" });
760
+ execSync(`sudo systemctl --user enable ${SYSTEMD_UNIT}`, {
761
+ stdio: "inherit",
762
+ });
763
+ execSync(`sudo systemctl --user start ${SYSTEMD_UNIT}`, {
764
+ stdio: "inherit",
765
+ });
766
+
767
+ return {
768
+ success: true,
769
+ method: "systemd (sudo)",
770
+ name: SYSTEMD_UNIT,
771
+ path: unitPath,
772
+ };
773
+ } catch (sudoErr) {
774
+ console.log(
775
+ " ⚠️ systemd with sudo failed — falling back to crontab.",
776
+ );
777
+ // Fall through to crontab
778
+ }
779
+ }
780
+ } else {
781
+ console.log(" ℹ️ systemd user session not available — using crontab.");
782
+ }
783
+
784
+ // Strategy 2: crontab @reboot fallback (works everywhere, no root needed)
785
+ return installCrontab(options);
786
+ }
787
+
788
+ /**
789
+ * Install via crontab @reboot entry. Works on any Linux without root.
790
+ */
791
+ function installCrontab(options = {}) {
792
+ const nodePath = getNodePath();
793
+ const cliPath = getCliPath();
794
+ const logDir = getLogDir();
795
+ const marker = getCronMarker();
796
+ const daemon = options.daemon !== false ? " --daemon" : "";
797
+ const cronLine = `@reboot cd ${getWorkingDirectory()} && ${nodePath} ${cliPath}${daemon} >> ${logDir}/startup.log 2>> ${logDir}/startup.error.log ${marker}`;
798
+
799
+ try {
800
+ // Get current crontab
801
+ let existing = "";
802
+ try {
803
+ existing = execSync("crontab -l", {
804
+ encoding: "utf8",
805
+ stdio: ["pipe", "pipe", "ignore"],
806
+ });
807
+ } catch {
808
+ /* no crontab yet — that's fine */
809
+ }
810
+
811
+ // Remove any existing openfleet entry
812
+ const lines = existing.split("\n").filter((l) => !l.includes(marker));
813
+ lines.push(cronLine);
814
+
815
+ // Write new crontab
816
+ const newCrontab =
817
+ lines
818
+ .join("\n")
819
+ .replace(/\n{3,}/g, "\n\n")
820
+ .trim() + "\n";
821
+ execSync("crontab -", {
822
+ input: newCrontab,
823
+ stdio: ["pipe", "pipe", "pipe"],
824
+ });
825
+
826
+ return {
827
+ success: true,
828
+ method: "crontab @reboot",
829
+ name: "crontab",
830
+ };
831
+ } catch (err) {
832
+ return {
833
+ success: false,
834
+ method: "crontab",
835
+ error: err.message,
836
+ };
837
+ }
838
+ }
839
+
840
+ async function removeLinux() {
841
+ const results = [];
842
+
843
+ // Remove systemd unit if present
844
+ const unitPath = getSystemdUnitPath();
845
+ if (existsSync(unitPath)) {
846
+ try {
847
+ try {
848
+ execSync(`systemctl --user stop ${SYSTEMD_UNIT}`, { stdio: "ignore" });
849
+ } catch {
850
+ /* ok */
851
+ }
852
+ try {
853
+ execSync(`systemctl --user disable ${SYSTEMD_UNIT}`, {
854
+ stdio: "ignore",
855
+ });
856
+ } catch {
857
+ /* ok */
858
+ }
859
+ unlinkSync(unitPath);
860
+ execSync("systemctl --user daemon-reload", { stdio: "ignore" });
861
+ results.push({ success: true, method: "systemd" });
862
+ } catch (err) {
863
+ const isPermission =
864
+ err.message?.includes("Permission denied") ||
865
+ err.message?.includes("EACCES");
866
+
867
+ if (isPermission) {
868
+ console.log(" ℹ️ Permission required — trying sudo...");
869
+ try {
870
+ execSync(`sudo systemctl --user stop ${SYSTEMD_UNIT}`, {
871
+ stdio: "inherit",
872
+ });
873
+ execSync(`sudo systemctl --user disable ${SYSTEMD_UNIT}`, {
874
+ stdio: "inherit",
875
+ });
876
+ if (existsSync(unitPath)) unlinkSync(unitPath);
877
+ execSync("sudo systemctl --user daemon-reload", { stdio: "inherit" });
878
+ results.push({ success: true, method: "systemd (sudo)" });
879
+ } catch (sudoErr) {
880
+ results.push({
881
+ success: false,
882
+ method: "systemd",
883
+ error: sudoErr.message,
884
+ });
885
+ }
886
+ } else {
887
+ results.push({ success: false, method: "systemd", error: err.message });
888
+ }
889
+ }
890
+ }
891
+
892
+ // Remove crontab entry if present
893
+ const marker = getCronMarker();
894
+ try {
895
+ const existing = execSync("crontab -l", {
896
+ encoding: "utf8",
897
+ stdio: ["pipe", "pipe", "ignore"],
898
+ });
899
+ if (existing.includes(marker)) {
900
+ const filtered =
901
+ existing
902
+ .split("\n")
903
+ .filter((l) => !l.includes(marker))
904
+ .join("\n")
905
+ .replace(/\n{3,}/g, "\n\n")
906
+ .trim() + "\n";
907
+ execSync("crontab -", {
908
+ input: filtered,
909
+ stdio: ["pipe", "pipe", "pipe"],
910
+ });
911
+ results.push({ success: true, method: "crontab" });
912
+ }
913
+ } catch {
914
+ /* no crontab — fine */
915
+ }
916
+
917
+ if (results.length === 0) {
918
+ return { success: true, method: "none (nothing to remove)" };
919
+ }
920
+
921
+ const anySuccess = results.some((r) => r.success);
922
+ return {
923
+ success: anySuccess,
924
+ method: results.map((r) => r.method).join(" + "),
925
+ error: anySuccess
926
+ ? undefined
927
+ : results
928
+ .map((r) => r.error)
929
+ .filter(Boolean)
930
+ .join("; "),
931
+ };
932
+ }
933
+
934
+ function statusLinux() {
935
+ // Check systemd first
936
+ const unitPath = getSystemdUnitPath();
937
+ if (existsSync(unitPath)) {
938
+ try {
939
+ const output = execSync(`systemctl --user is-active ${SYSTEMD_UNIT}`, {
940
+ encoding: "utf8",
941
+ stdio: ["pipe", "pipe", "ignore"],
942
+ }).trim();
943
+ return {
944
+ installed: true,
945
+ enabled: true,
946
+ running: output === "active",
947
+ method: "systemd",
948
+ name: SYSTEMD_UNIT,
949
+ path: unitPath,
950
+ };
951
+ } catch {
952
+ return {
953
+ installed: true,
954
+ enabled: true,
955
+ running: false,
956
+ method: "systemd",
957
+ name: SYSTEMD_UNIT,
958
+ path: unitPath,
959
+ };
960
+ }
961
+ }
962
+
963
+ // Check crontab fallback
964
+ const marker = getCronMarker();
965
+ try {
966
+ const existing = execSync("crontab -l", {
967
+ encoding: "utf8",
968
+ stdio: ["pipe", "pipe", "ignore"],
969
+ });
970
+ if (existing.includes(marker)) {
971
+ return {
972
+ installed: true,
973
+ enabled: true,
974
+ method: "crontab @reboot",
975
+ name: "crontab",
976
+ };
977
+ }
978
+ } catch {
979
+ /* no crontab */
980
+ }
981
+
982
+ return { installed: false, method: "systemd" };
983
+ }
984
+
985
+ // ── Public API ───────────────────────────────────────────────────────────────
986
+
987
+ /**
988
+ * Install openfleet as a startup service.
989
+ * @param {{ daemon?: boolean }} options Whether to start in daemon mode (default: true)
990
+ * @returns {Promise<{ success: boolean, method: string, error?: string, name?: string, path?: string }>}
991
+ */
992
+ export async function installStartupService(options = {}) {
993
+ const opts = { daemon: true, ...options };
994
+ const platform = getPlatform();
995
+
996
+ switch (platform) {
997
+ case "windows":
998
+ return installWindows(opts);
999
+ case "macos":
1000
+ return installMacOS(opts);
1001
+ case "linux":
1002
+ return installLinux(opts);
1003
+ default:
1004
+ return {
1005
+ success: false,
1006
+ method: "none",
1007
+ error: `Unsupported platform: ${process.platform}`,
1008
+ };
1009
+ }
1010
+ }
1011
+
1012
+ /**
1013
+ * Remove openfleet from startup services.
1014
+ * @returns {Promise<{ success: boolean, method: string, error?: string }>}
1015
+ */
1016
+ export async function removeStartupService() {
1017
+ const platform = getPlatform();
1018
+
1019
+ switch (platform) {
1020
+ case "windows":
1021
+ return removeWindows();
1022
+ case "macos":
1023
+ return removeMacOS();
1024
+ case "linux":
1025
+ return removeLinux();
1026
+ default:
1027
+ return {
1028
+ success: false,
1029
+ method: "none",
1030
+ error: `Unsupported platform: ${process.platform}`,
1031
+ };
1032
+ }
1033
+ }
1034
+
1035
+ /**
1036
+ * Get the current startup service status.
1037
+ * @returns {{ installed: boolean, enabled?: boolean, running?: boolean, method: string, name?: string, path?: string }}
1038
+ */
1039
+ export function getStartupStatus() {
1040
+ const platform = getPlatform();
1041
+
1042
+ switch (platform) {
1043
+ case "windows":
1044
+ return statusWindows();
1045
+ case "macos":
1046
+ return statusMacOS();
1047
+ case "linux":
1048
+ return statusLinux();
1049
+ default:
1050
+ return { installed: false, method: "none" };
1051
+ }
1052
+ }
1053
+
1054
+ /**
1055
+ * Get human-readable platform method name.
1056
+ * @returns {string}
1057
+ */
1058
+ export function getStartupMethodName() {
1059
+ const platform = getPlatform();
1060
+ switch (platform) {
1061
+ case "windows":
1062
+ return "Windows Task Scheduler";
1063
+ case "macos":
1064
+ return "macOS launchd";
1065
+ case "linux":
1066
+ return "systemd user service";
1067
+ default:
1068
+ return "unsupported";
1069
+ }
1070
+ }