@indigoai-us/hq-cli 5.345.202 → 5.345.204

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 (64) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/command-catalog.generated.d.ts +24 -0
  3. package/dist/command-catalog.generated.js +26 -0
  4. package/dist/commands/bot.js +7 -2
  5. package/dist/commands/capacity.js +10 -2
  6. package/dist/commands/cloud.d.ts +14 -2
  7. package/dist/commands/cloud.js +25 -16
  8. package/dist/commands/daemon.d.ts +2 -1
  9. package/dist/commands/daemon.js +225 -4
  10. package/dist/commands/integrations-core.d.ts +3 -0
  11. package/dist/commands/integrations-core.js +11 -0
  12. package/dist/commands/integrations-manage.js +2 -2
  13. package/dist/commands/integrations.js +5 -5
  14. package/dist/commands/sync-core.js +4 -1
  15. package/dist/lib/bot/claude-login.d.ts +7 -1
  16. package/dist/lib/bot/claude-login.js +56 -16
  17. package/dist/lib/bot/executive-settings.d.ts +1 -1
  18. package/dist/lib/bot/executive-settings.js +8 -6
  19. package/dist/lib/bot/jobs/meeting-detect.js +2 -2
  20. package/dist/lib/bot/jobs/meeting-rider.js +2 -2
  21. package/dist/lib/bot/run.js +3 -3
  22. package/dist/lib/capacity/codex-availability.d.ts +6 -0
  23. package/dist/lib/capacity/codex-availability.js +33 -0
  24. package/dist/lib/capacity/collectors/claude-oauth.d.ts +53 -5
  25. package/dist/lib/capacity/collectors/claude-oauth.js +251 -71
  26. package/dist/lib/capacity/ledger.d.ts +0 -11
  27. package/dist/lib/capacity/ledger.js +30 -3
  28. package/dist/lib/capacity/route.js +11 -5
  29. package/dist/lib/daemon/catalog.d.ts +3 -0
  30. package/dist/lib/daemon/catalog.js +17 -4
  31. package/dist/lib/daemon/handoff.d.ts +2 -0
  32. package/dist/lib/daemon/handoff.js +9 -2
  33. package/dist/lib/daemon/install.d.ts +99 -0
  34. package/dist/lib/daemon/install.js +316 -7
  35. package/dist/lib/daemon/migrate.d.ts +3 -1
  36. package/dist/lib/daemon/migrate.js +14 -2
  37. package/dist/lib/daemon/plist-refresh.d.ts +34 -0
  38. package/dist/lib/daemon/plist-refresh.js +62 -0
  39. package/dist/lib/daemon/run.js +27 -2
  40. package/dist/lib/daemon/selfcheck.d.ts +71 -0
  41. package/dist/lib/daemon/selfcheck.js +150 -0
  42. package/dist/lib/daemon/supervisor.d.ts +31 -0
  43. package/dist/lib/daemon/supervisor.js +130 -6
  44. package/dist/lib/daemon/unit-holds.js +2 -1
  45. package/dist/lib/lanes/config.js +13 -0
  46. package/dist/lib/lanes/throttle.d.ts +12 -1
  47. package/dist/lib/lanes/throttle.js +27 -8
  48. package/dist/lib/lanes/types.d.ts +5 -0
  49. package/dist/lib/service-manager/launchd.d.ts +25 -0
  50. package/dist/lib/service-manager/launchd.js +51 -3
  51. package/dist/lib/service-manager/migration.d.ts +14 -0
  52. package/dist/lib/service-manager/migration.js +34 -0
  53. package/dist/lib/service-manager/systemd.d.ts +13 -0
  54. package/dist/lib/service-manager/systemd.js +19 -0
  55. package/dist/lib/service-manager/types.d.ts +8 -0
  56. package/dist/lib/signals/steps/extract.d.ts +110 -4
  57. package/dist/lib/signals/steps/extract.js +520 -39
  58. package/dist/lib/sync-core/engine-home.d.ts +2 -1
  59. package/dist/lib/sync-core/engine-home.js +25 -8
  60. package/dist/lib/sync-core/reconcile.d.ts +7 -2
  61. package/dist/lib/sync-core/reconcile.js +32 -9
  62. package/dist/lib/sync-core/release.d.ts +35 -3
  63. package/dist/lib/sync-core/release.js +95 -12
  64. package/package.json +7 -7
@@ -163,6 +163,105 @@ export declare function classifyWindowsTask(run: RunCommand, task: string, host:
163
163
  /** What to tell the user when HQ did not stop pid `pid` because it could not verify it. */
164
164
  export declare function oldDaemonWarning(pid: number, reason: string): string;
165
165
  export declare function installDaemonUnit(host: DaemonHost, options: InstallOptions, deps: InstallDeps): DaemonUnitResult;
166
+ /** Install-level bootstrap retries after launchd left the job unloaded. */
167
+ export declare const DAEMON_BOOTSTRAP_RETRIES = 3;
168
+ export declare const DAEMON_BOOTSTRAP_RETRY_MS = 2000;
169
+ /**
170
+ * Bootstrap the daemon agent again when an install left it unloaded, up to
171
+ * DAEMON_BOOTSTRAP_RETRIES times, then kickstart it. Returns the error that
172
+ * says the agent is not loaded when every retry failed.
173
+ */
174
+ export declare function retryLaunchdBootstrap(plistPath: string, run: RunCommand, uid: number, sleep?: SleepFn): {
175
+ ok: boolean;
176
+ error?: string;
177
+ };
178
+ export interface RefreshDaemonPlistResult {
179
+ /**
180
+ * - `none`: no daemon plist here (not installed; nothing to upgrade);
181
+ * - `current`: the plist already loads without HQ.app;
182
+ * - `rewritten`: the plist was rewritten; it was not loaded, so nothing was reloaded;
183
+ * - `reloaded`: the plist was rewritten and launchd now runs the new definition.
184
+ */
185
+ action: "none" | "current" | "rewritten" | "reloaded";
186
+ plistPath: string;
187
+ /** The rewrite or reload did not finish; what is left and how to fix it. */
188
+ error?: string;
189
+ }
190
+ /**
191
+ * Upgrade a daemon LaunchAgent written by an older install (run through the
192
+ * HQ.app launcher and associated with the app's bundle, which launchd skips
193
+ * at login) to the app-independent form, and reload it when it is loaded.
194
+ *
195
+ * Nothing but `hq daemon install` used to rewrite the unit, so a machine that
196
+ * only ever self-updated kept the old plist. This runs from `hq daemon start`,
197
+ * and from the detached helper the managed daemon spawns at startup when it
198
+ * finds the old plist (see daemonPlistNeedsRefresh). It must not run inside
199
+ * the daemon's own job: the bootout would end the caller before the bootstrap.
200
+ */
201
+ export declare function refreshDaemonPlist(host: Pick<DaemonHost, "home" | "uid">, deps: {
202
+ run: RunCommand;
203
+ sleep?: SleepFn;
204
+ }): RefreshDaemonPlistResult;
205
+ /** The daemon plist on disk still runs through HQ.app or names its bundle. */
206
+ export declare function daemonPlistNeedsRefresh(home: string): boolean;
207
+ /**
208
+ * Who runs the daemon right now, as the service manager and the pid lock see it:
209
+ *
210
+ * - `managed`: the OS unit is loaded (and, where the manager reports one, its
211
+ * pid is the daemon holding the lock), so it comes back after a crash or login;
212
+ * - `unmanaged`: a daemon holds the lock, but the OS unit is not loaded or runs
213
+ * a different process (started by hand, or as the desktop app's child);
214
+ * - `not-loaded`: the OS unit is not loaded and no daemon runs.
215
+ */
216
+ export type DaemonSupervision = "managed" | "unmanaged" | "not-loaded";
217
+ export interface DaemonServiceStatus {
218
+ supervision: DaemonSupervision;
219
+ /** Which service manager was asked. */
220
+ manager: "launchd" | "systemd-user" | "systemd-system" | "schtasks" | "none";
221
+ /** The unit file (plist, unit, task) is present. */
222
+ unitInstalled: boolean;
223
+ /** The manager has the unit loaded / enabled. */
224
+ loaded: boolean;
225
+ /** The manager reports the unit's process as running. */
226
+ active: boolean;
227
+ /** The manager's pid for the unit, when it reports one. */
228
+ managerPid?: number;
229
+ /** The pid of the live daemon holding the lock, or null. */
230
+ daemonPid: number | null;
231
+ /** One line for people: what the manager said. */
232
+ detail: string;
233
+ /**
234
+ * Problems with the unit that do not change who runs the daemon now, such
235
+ * as a plist launchd skips at login. One line each, with the fix.
236
+ */
237
+ warnings?: string[];
238
+ }
239
+ export declare function describeSupervision(status: DaemonServiceStatus): string;
240
+ /**
241
+ * Ask launchd, systemd or Task Scheduler whether the daemon's unit is loaded,
242
+ * and combine that with the pid lock. `daemonPid` is the live daemon from the
243
+ * lock (null when none runs).
244
+ */
245
+ export declare function daemonServiceStatus(host: Pick<DaemonHost, "platform" | "home" | "uid" | "systemUnitDir" | "windowsSid" | "windowsUser">, run: RunCommand, daemonPid: number | null): DaemonServiceStatus;
246
+ export interface StartDaemonResult {
247
+ /**
248
+ * What was done: nothing (already running), loaded the unit, started it, or
249
+ * reloaded it after rewriting an older install's plist (macOS).
250
+ */
251
+ action: "already-running" | "loaded" | "started" | "reloaded";
252
+ status: DaemonServiceStatus;
253
+ /** macOS: what happened to an older install's plist before starting. */
254
+ refresh?: RefreshDaemonPlistResult;
255
+ }
256
+ /**
257
+ * `hq daemon start`: load the daemon's OS unit when the manager does not have
258
+ * it, then start it. The unit file must already exist (the caller installs it
259
+ * first when it does not). Throws when the manager refuses.
260
+ */
261
+ export declare function startDaemonUnit(host: DaemonHost, deps: InstallDeps & {
262
+ daemonPid: () => number | null;
263
+ waitMs?: number;
264
+ }): StartDaemonResult;
166
265
  /**
167
266
  * PowerShell script that lists Windows sessions as `id|state|DOMAIN\user`
168
267
  * lines through WTSEnumerateSessions and WTSQuerySessionInformation. State is
@@ -18,8 +18,9 @@
18
18
  import * as fs from "node:fs";
19
19
  import * as os from "node:os";
20
20
  import * as path from "node:path";
21
- import { activateLaunchd, blockingSleep, deactivateLaunchd, defaultRunCommand, installLaunchdPlist, launchdIsLoaded, launchdPlistPath, renderLaunchdPlist, } from "../service-manager/launchd.js";
22
- import { activateSystemd, deactivateSystemd, renderSystemdUserUnit, systemdUnitPath, } from "../service-manager/systemd.js";
21
+ import { activateLaunchd, blockingSleep, bootstrapLaunchd, launchdJobInfo, launchdServiceTarget, deactivateLaunchd, defaultRunCommand, installLaunchdPlist, launchdIsLoaded, launchdPlistPath, launchdPlistProgramArguments, renderLaunchdPlist, } from "../service-manager/launchd.js";
22
+ import { activateSystemd, deactivateSystemd, renderSystemdUserUnit, systemdUnitInfo, systemdUnitPath, } from "../service-manager/systemd.js";
23
+ import { appIndependentLaunchdPlist } from "../service-manager/migration.js";
23
24
  import { escapeXml, shellQuote } from "../service-manager/types.js";
24
25
  import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
25
26
  import { userToolDirs } from "./tools.js";
@@ -108,6 +109,7 @@ function daemonSpec(host) {
108
109
  env: host.env ?? {},
109
110
  restartSec: 10,
110
111
  unitName: DAEMON_SYSTEMD_UNIT,
112
+ independentOfApp: true,
111
113
  };
112
114
  }
113
115
  function serviceHost(host) {
@@ -454,16 +456,51 @@ export function installDaemonUnit(host, options, deps) {
454
456
  });
455
457
  }
456
458
  catch (err) {
459
+ const run = deps.run ?? defaultRunCommand;
460
+ const message = err instanceof Error ? err.message : String(err);
461
+ const installed = fs.existsSync(plan.unitPath);
462
+ // The reload gave up with the job unloaded: try the bootstrap again
463
+ // before reporting, since an unloaded agent never starts at login.
464
+ if (installed && !launchdIsLoaded(DAEMON_LAUNCHD_LABEL, run, host.uid)) {
465
+ const retried = retryLaunchdBootstrap(plan.unitPath, run, host.uid, deps.sleep);
466
+ if (retried.ok)
467
+ return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: true };
468
+ return { kind: plan.kind, unitPath: plan.unitPath, installed, running: false, error: `${message}\n${retried.error}` };
469
+ }
470
+ // The reload failed but launchd still runs the previous definition: a
471
+ // daemon is running under launchd. Say so, and that the new plist is only
472
+ // read at the next load (login, or hq daemon start after a bootout).
473
+ if (installed && launchdIsLoaded(DAEMON_LAUNCHD_LABEL, run, host.uid)) {
474
+ return {
475
+ kind: plan.kind,
476
+ unitPath: plan.unitPath,
477
+ installed: true,
478
+ running: true,
479
+ warning: `The daemon is still running under launchd with its previous definition. ${message}`,
480
+ };
481
+ }
457
482
  return {
458
483
  kind: plan.kind,
459
484
  unitPath: plan.unitPath,
460
- installed: fs.existsSync(plan.unitPath),
461
- running: launchdIsLoaded(DAEMON_LAUNCHD_LABEL, deps.run ?? defaultRunCommand, host.uid),
462
- error: err instanceof Error ? err.message : String(err),
485
+ installed,
486
+ running: false,
487
+ error: message,
463
488
  };
464
489
  }
465
- if (!plist.loaded)
466
- return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: false };
490
+ if (!plist.loaded) {
491
+ // installLaunchdPlist found the job unloaded after activating it. That is
492
+ // a failure, never a quiet success: retry the bootstrap, then report it.
493
+ const retried = retryLaunchdBootstrap(plan.unitPath, deps.run ?? defaultRunCommand, host.uid, deps.sleep);
494
+ if (!retried.ok) {
495
+ return {
496
+ kind: plan.kind,
497
+ unitPath: plan.unitPath,
498
+ installed: true,
499
+ running: false,
500
+ error: retried.error,
501
+ };
502
+ }
503
+ }
467
504
  }
468
505
  else if (plan.kind === "schtasks")
469
506
  fs.writeFileSync(plan.unitPath, encodeWindowsTaskXml(plan.rendered));
@@ -507,6 +544,278 @@ export function installDaemonUnit(host, options, deps) {
507
544
  };
508
545
  }
509
546
  }
547
+ /** Install-level bootstrap retries after launchd left the job unloaded. */
548
+ export const DAEMON_BOOTSTRAP_RETRIES = 3;
549
+ export const DAEMON_BOOTSTRAP_RETRY_MS = 2_000;
550
+ /**
551
+ * Bootstrap the daemon agent again when an install left it unloaded, up to
552
+ * DAEMON_BOOTSTRAP_RETRIES times, then kickstart it. Returns the error that
553
+ * says the agent is not loaded when every retry failed.
554
+ */
555
+ export function retryLaunchdBootstrap(plistPath, run, uid, sleep = blockingSleep) {
556
+ let last = "";
557
+ for (let attempt = 1; attempt <= DAEMON_BOOTSTRAP_RETRIES; attempt++) {
558
+ const r = bootstrapLaunchd(plistPath, run, uid, { sleep });
559
+ if (r.ok && launchdJobInfo(DAEMON_LAUNCHD_LABEL, run, uid).loaded) {
560
+ run("launchctl", ["kickstart", launchdServiceTarget(plistPath, uid)]);
561
+ return { ok: true };
562
+ }
563
+ last = r.error ?? "launchd does not list the job after bootstrap";
564
+ if (attempt < DAEMON_BOOTSTRAP_RETRIES)
565
+ sleep(DAEMON_BOOTSTRAP_RETRY_MS);
566
+ }
567
+ const target = launchdServiceTarget(plistPath, uid);
568
+ return {
569
+ ok: false,
570
+ error: `launchctl bootstrap gui/${uid} ${plistPath} failed ${DAEMON_BOOTSTRAP_RETRIES} times: ${last}. ` +
571
+ `${target} is NOT loaded, so the daemon will not start at login. Retry with: hq daemon start`,
572
+ };
573
+ }
574
+ /**
575
+ * Upgrade a daemon LaunchAgent written by an older install (run through the
576
+ * HQ.app launcher and associated with the app's bundle, which launchd skips
577
+ * at login) to the app-independent form, and reload it when it is loaded.
578
+ *
579
+ * Nothing but `hq daemon install` used to rewrite the unit, so a machine that
580
+ * only ever self-updated kept the old plist. This runs from `hq daemon start`,
581
+ * and from the detached helper the managed daemon spawns at startup when it
582
+ * finds the old plist (see daemonPlistNeedsRefresh). It must not run inside
583
+ * the daemon's own job: the bootout would end the caller before the bootstrap.
584
+ */
585
+ export function refreshDaemonPlist(host, deps) {
586
+ const plistPath = launchdPlistPath(host.home, { label: DAEMON_LAUNCHD_LABEL });
587
+ if (!fs.existsSync(plistPath))
588
+ return { action: "none", plistPath };
589
+ const source = fs.readFileSync(plistPath, "utf8");
590
+ let updated;
591
+ try {
592
+ updated = appIndependentLaunchdPlist(source);
593
+ }
594
+ catch (err) {
595
+ return { action: "current", plistPath, error: `${err instanceof Error ? err.message : String(err)}: ${plistPath}. Run: hq daemon install` };
596
+ }
597
+ if (updated === null) {
598
+ // The file is current, but an earlier reload may have failed and left
599
+ // launchd running the old definition: reload it so it matches the file.
600
+ if (!loadedJobRunsThroughApp(launchdJobInfo(DAEMON_LAUNCHD_LABEL, deps.run, host.uid)))
601
+ return { action: "current", plistPath };
602
+ }
603
+ else {
604
+ const mode = fs.statSync(plistPath).mode & 0o777;
605
+ const tmp = `${plistPath}.${process.pid}.refresh.tmp`;
606
+ fs.writeFileSync(tmp, updated, { mode });
607
+ fs.renameSync(tmp, plistPath);
608
+ if (!launchdIsLoaded(DAEMON_LAUNCHD_LABEL, deps.run, host.uid))
609
+ return { action: "rewritten", plistPath };
610
+ }
611
+ try {
612
+ activateLaunchd(plistPath, deps.run, host.uid, { sleep: deps.sleep });
613
+ }
614
+ catch (err) {
615
+ const message = err instanceof Error ? err.message : String(err);
616
+ if (launchdIsLoaded(DAEMON_LAUNCHD_LABEL, deps.run, host.uid)) {
617
+ // The old definition still runs; the rewritten file is what launchd reads at login.
618
+ return { action: "rewritten", plistPath, error: message };
619
+ }
620
+ const retried = retryLaunchdBootstrap(plistPath, deps.run, host.uid, deps.sleep);
621
+ if (!retried.ok)
622
+ return { action: "rewritten", plistPath, error: `${message}\n${retried.error}` };
623
+ }
624
+ return { action: "reloaded", plistPath };
625
+ }
626
+ /** launchd's loaded copy of the daemon job runs through the HQ.app launcher. */
627
+ function loadedJobRunsThroughApp(job) {
628
+ return job.loaded && job.arguments !== undefined && job.arguments.includes("--hq-launchd-task");
629
+ }
630
+ /** The daemon plist on disk still runs through HQ.app or names its bundle. */
631
+ export function daemonPlistNeedsRefresh(home) {
632
+ const plistPath = launchdPlistPath(home, { label: DAEMON_LAUNCHD_LABEL });
633
+ try {
634
+ return appIndependentLaunchdPlist(fs.readFileSync(plistPath, "utf8")) !== null;
635
+ }
636
+ catch {
637
+ return false;
638
+ }
639
+ }
640
+ export function describeSupervision(status) {
641
+ switch (status.supervision) {
642
+ case "managed":
643
+ return `running under the service manager (${status.detail})`;
644
+ case "unmanaged":
645
+ return `running outside the service manager (${status.detail}); it will not come back after a crash or restart. Run: hq daemon start`;
646
+ case "not-loaded":
647
+ return `not loaded (${status.detail}). Run: hq daemon start`;
648
+ }
649
+ }
650
+ /**
651
+ * Ask launchd, systemd or Task Scheduler whether the daemon's unit is loaded,
652
+ * and combine that with the pid lock. `daemonPid` is the live daemon from the
653
+ * lock (null when none runs).
654
+ */
655
+ export function daemonServiceStatus(host, run, daemonPid) {
656
+ const classify = (base, pidsComparable = false) => {
657
+ // Loaded but not running (launchd throttling a restart) is still managed:
658
+ // the manager brings it back. A live daemon the manager does not report
659
+ // as running was started some other way. When the unit runs node directly
660
+ // its pid must be the daemon's: a different pid is a second daemon that
661
+ // lost the lock (for example to one the desktop app hosts) and is being
662
+ // respawned. An older install's plist runs the HQ.app launcher, whose pid
663
+ // is never the daemon's, so pids are not compared there.
664
+ let supervision;
665
+ if (!base.loaded)
666
+ supervision = daemonPid !== null ? "unmanaged" : "not-loaded";
667
+ else if (daemonPid !== null && !base.active)
668
+ supervision = "unmanaged";
669
+ else if (pidsComparable && daemonPid !== null && base.managerPid !== undefined && base.managerPid !== daemonPid) {
670
+ supervision = "unmanaged";
671
+ }
672
+ else
673
+ supervision = "managed";
674
+ return { ...base, supervision, daemonPid };
675
+ };
676
+ switch (host.platform) {
677
+ case "darwin": {
678
+ const plist = launchdPlistPath(host.home, { label: DAEMON_LAUNCHD_LABEL });
679
+ const unitInstalled = fs.existsSync(plist);
680
+ const job = launchdJobInfo(DAEMON_LAUNCHD_LABEL, run, host.uid);
681
+ const target = `gui/${host.uid}/${DAEMON_LAUNCHD_LABEL}`;
682
+ let fileNodeDirect;
683
+ try {
684
+ fileNodeDirect = unitInstalled && !launchdPlistProgramArguments(fs.readFileSync(plist, "utf8")).includes("--hq-launchd-task");
685
+ }
686
+ catch {
687
+ fileNodeDirect = false;
688
+ }
689
+ // What runs now is the loaded job, which differs from the file on disk
690
+ // after a rewrite whose reload failed: go by the job when launchd prints it.
691
+ const jobThroughApp = loadedJobRunsThroughApp(job);
692
+ const nodeDirect = job.loaded && job.arguments !== undefined ? !jobThroughApp : fileNodeDirect;
693
+ const warnings = [];
694
+ if (unitInstalled && daemonPlistNeedsRefresh(host.home)) {
695
+ warnings.push(`${plist} still runs through HQ.app, so launchd skips the daemon at login. Run: hq daemon start`);
696
+ }
697
+ else if (jobThroughApp) {
698
+ warnings.push(`launchd still runs the daemon's previous definition, which goes through HQ.app; ${plist} was rewritten but not reloaded. Run: hq daemon start`);
699
+ }
700
+ const pidMismatch = nodeDirect && job.pid !== undefined && daemonPid !== null && job.pid !== daemonPid;
701
+ const detail = job.loaded
702
+ ? `launchd ${target}: ${job.state ?? "loaded"}${job.pid ? `, pid ${job.pid}` : ""}` +
703
+ (pidMismatch ? `, not the daemon holding the lock (pid ${daemonPid})` : "")
704
+ : `launchd has no ${target}${unitInstalled ? "" : ` and ${plist} is missing`}`;
705
+ return classify({
706
+ manager: "launchd",
707
+ unitInstalled,
708
+ loaded: job.loaded,
709
+ active: job.loaded && (job.pid !== undefined || job.state === "running"),
710
+ ...(job.pid ? { managerPid: job.pid } : {}),
711
+ detail,
712
+ ...(warnings.length > 0 ? { warnings } : {}),
713
+ }, nodeDirect);
714
+ }
715
+ case "linux": {
716
+ const systemUnit = path.join(host.systemUnitDir, DAEMON_SYSTEMD_UNIT);
717
+ const userUnit = path.join(host.home, ".config", "systemd", "user", DAEMON_SYSTEMD_UNIT);
718
+ const scope = fs.existsSync(systemUnit) ? "system" : "user";
719
+ const unitInstalled = scope === "system" || fs.existsSync(userUnit);
720
+ const info = systemdUnitInfo(DAEMON_SYSTEMD_UNIT, run, scope);
721
+ return classify({
722
+ manager: scope === "system" ? "systemd-system" : "systemd-user",
723
+ unitInstalled,
724
+ loaded: unitInstalled && info.enabled,
725
+ active: info.active,
726
+ ...(info.pid ? { managerPid: info.pid } : {}),
727
+ detail: `systemd ${scope} ${DAEMON_SYSTEMD_UNIT}: ${info.enabledState}, ${info.activeState}${info.pid ? `, pid ${info.pid}` : ""}`,
728
+ }, true);
729
+ }
730
+ case "win32": {
731
+ const task = windowsTaskName({ windowsSid: host.windowsSid, windowsUser: host.windowsUser });
732
+ const probe = run("schtasks.exe", ["/Query", "/TN", task, "/FO", "CSV", "/NH"]);
733
+ const loaded = probe.status === 0;
734
+ const active = loaded && /,\s*"?Running"?\s*$/i.test(probe.stdout.replace(/\0/g, "").trim());
735
+ return classify({
736
+ manager: "schtasks",
737
+ unitInstalled: loaded,
738
+ loaded,
739
+ active,
740
+ detail: loaded ? `Scheduled Task ${task}${active ? " is running" : " is not running"}` : `no Scheduled Task ${task}`,
741
+ });
742
+ }
743
+ default:
744
+ return { supervision: daemonPid !== null ? "unmanaged" : "not-loaded", manager: "none", unitInstalled: false, loaded: false, active: false, daemonPid, detail: `${host.platform} has no supported service manager` };
745
+ }
746
+ }
747
+ /**
748
+ * `hq daemon start`: load the daemon's OS unit when the manager does not have
749
+ * it, then start it. The unit file must already exist (the caller installs it
750
+ * first when it does not). Throws when the manager refuses.
751
+ */
752
+ export function startDaemonUnit(host, deps) {
753
+ const sleep = deps.sleep ?? blockingSleep;
754
+ // An older install's plist runs through HQ.app and is skipped at login:
755
+ // rewrite it first, so the unit this loads or starts is the fixed one.
756
+ const previousPid = deps.daemonPid();
757
+ const refresh = host.platform === "darwin" ? refreshDaemonPlist(host, { run: deps.run, sleep }) : undefined;
758
+ const withRefresh = refresh && (refresh.action === "rewritten" || refresh.action === "reloaded" || refresh.error) ? { refresh } : {};
759
+ const waitForManaged = (action, previousPid) => {
760
+ const waitMs = deps.waitMs ?? 15_000;
761
+ const ready = (s) => s.supervision === "managed" && s.daemonPid !== null && (previousPid === null || s.daemonPid !== previousPid);
762
+ let status = daemonServiceStatus(host, deps.run, deps.daemonPid());
763
+ for (let waited = 0; waited < waitMs && !ready(status); waited += 500) {
764
+ sleep(500);
765
+ status = daemonServiceStatus(host, deps.run, deps.daemonPid());
766
+ }
767
+ return { action, status, ...withRefresh };
768
+ };
769
+ if (refresh?.action === "reloaded")
770
+ return waitForManaged("reloaded", previousPid);
771
+ const before = daemonServiceStatus(host, deps.run, deps.daemonPid());
772
+ if (before.supervision === "managed" && before.daemonPid !== null)
773
+ return { action: "already-running", status: before, ...withRefresh };
774
+ let action = "started";
775
+ switch (before.manager) {
776
+ case "launchd": {
777
+ const plist = launchdPlistPath(host.home, { label: DAEMON_LAUNCHD_LABEL });
778
+ const target = launchdServiceTarget(plist, host.uid);
779
+ if (!before.loaded) {
780
+ const r = retryLaunchdBootstrap(plist, deps.run, host.uid, sleep);
781
+ if (!r.ok)
782
+ throw new Error(r.error);
783
+ action = "loaded";
784
+ }
785
+ else {
786
+ const kick = deps.run("launchctl", ["kickstart", target]);
787
+ if (kick.status !== 0)
788
+ throw commandError(`launchctl kickstart ${target}`, kick);
789
+ }
790
+ break;
791
+ }
792
+ case "systemd-user":
793
+ case "systemd-system": {
794
+ const prefix = before.manager === "systemd-user" ? ["--user"] : [];
795
+ if (before.manager === "systemd-system" && !host.isRoot) {
796
+ throw new Error("The daemon runs as a system unit on this box; starting it needs root. Run: sudo hq daemon start");
797
+ }
798
+ deps.run("systemctl", [...prefix, "daemon-reload"]);
799
+ const enable = deps.run("systemctl", [...prefix, "enable", "--now", DAEMON_SYSTEMD_UNIT]);
800
+ if (enable.status !== 0)
801
+ throw commandError(["systemctl", ...prefix, "enable", "--now", DAEMON_SYSTEMD_UNIT].join(" "), enable);
802
+ if (!before.loaded)
803
+ action = "loaded";
804
+ break;
805
+ }
806
+ case "schtasks": {
807
+ const task = windowsTaskName(host);
808
+ const r = deps.run("schtasks.exe", ["/Run", "/TN", task]);
809
+ if (r.status !== 0)
810
+ throw commandError("schtasks /Run", r);
811
+ break;
812
+ }
813
+ case "none":
814
+ throw new Error(`${host.platform} has no supported service manager; start the daemon with: hq daemon run`);
815
+ }
816
+ // Wait for the manager's daemon to take the lock.
817
+ return waitForManaged(action, null);
818
+ }
510
819
  /** A lock record names a different daemon than `previous` (new pid or new start time). */
511
820
  function isNewDaemon(now, previous) {
512
821
  return !previous || now.pid !== previous.pid || now.ts !== previous.ts;
@@ -16,7 +16,7 @@
16
16
  * user can edit), and a restore checks each one lands in the system unit
17
17
  * folder, so nobody but root decides what gets installed there.
18
18
  */
19
- import type { RunCommand } from "../service-manager/launchd.js";
19
+ import { type RunCommand, type SleepFn } from "../service-manager/launchd.js";
20
20
  import { type LegacyUnit } from "./legacy.js";
21
21
  export interface AdoptedUnit extends LegacyUnit {
22
22
  /** Where the unit's definition (plist, unit file, task XML) was moved. */
@@ -43,6 +43,8 @@ export interface MigrateDeps {
43
43
  uid: number;
44
44
  };
45
45
  now?: () => number;
46
+ /** Blocking sleep for launchd bootstrap retries (tests inject). */
47
+ sleep?: SleepFn;
46
48
  /** Tests inject a failing writer. */
47
49
  writeRecord?: (recordPath: string, units: AdoptedUnit[]) => void;
48
50
  }
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import * as fs from "node:fs";
20
20
  import * as path from "node:path";
21
+ import { bootstrapLaunchd } from "../service-manager/launchd.js";
21
22
  import { legacyMeshUnitsToRetire } from "./legacy.js";
22
23
  import { ensurePrivateDir } from "./paths.js";
23
24
  export function readAdoptedUnits(recordPath) {
@@ -102,7 +103,16 @@ function adoptOne(unit, deps) {
102
103
  }
103
104
  if (!fs.existsSync(unit.path))
104
105
  return { movedTo: unit.path, definitionMissing: true };
105
- moveFile(unit.path, to);
106
+ try {
107
+ moveFile(unit.path, to);
108
+ }
109
+ catch (err) {
110
+ // The agent is booted out but its plist did not move: load it again,
111
+ // so a failed takeover never leaves the service off.
112
+ const back = bootstrapLaunchd(unit.path, deps.run, deps.uid, { sleep: deps.sleep });
113
+ const reloaded = back.ok ? "it was loaded again" : `loading it again also failed (${back.error}); load it with: launchctl bootstrap gui/${deps.uid} ${unit.path}`;
114
+ throw new Error(`${err.message}; ${reloaded}`, { cause: err });
115
+ }
106
116
  return { movedTo: to };
107
117
  }
108
118
  case "systemd-user":
@@ -198,7 +208,9 @@ function restoreOne(unit, deps) {
198
208
  if (unit.definitionMissing)
199
209
  throw new Error("the original LaunchAgent plist is missing; this unit cannot be restored");
200
210
  moveBack(unit, unit.path);
201
- must(deps.run, "launchctl", ["bootstrap", `gui/${deps.uid}`, unit.path]);
211
+ const loaded = bootstrapLaunchd(unit.path, deps.run, deps.uid, { sleep: deps.sleep });
212
+ if (!loaded.ok)
213
+ throw new Error(`launchctl bootstrap gui/${deps.uid} ${unit.path} failed after ${loaded.attempts} attempts: ${loaded.error}`);
202
214
  return;
203
215
  }
204
216
  case "systemd-user":
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Upgrade an older install's daemon LaunchAgent without a manual
3
+ * `hq daemon install`.
4
+ *
5
+ * Self-update and `hq update` restart the daemon through a control request and
6
+ * never rewrite its unit, so a Mac that only ever self-updated keeps the plist
7
+ * that runs through HQ.app and names its bundle, which launchd skips at login.
8
+ * When the managed daemon starts and finds that plist, it spawns a detached
9
+ * `hq daemon start`, which rewrites the plist and reloads it (see
10
+ * refreshDaemonPlist). The helper runs in its own session, outside the
11
+ * daemon's job, so the bootout that ends this daemon does not end the helper
12
+ * before it bootstraps the new definition.
13
+ */
14
+ /** At most one helper per this long, so a rewrite that keeps failing cannot loop. */
15
+ export declare const PLIST_REFRESH_RETRY_MS: number;
16
+ export declare const PLIST_REFRESH_MARKER = "plist-refresh.json";
17
+ export type PlistRefreshOutcome = "not-applicable" | "current" | "recent" | "spawned";
18
+ export interface PlistRefreshDeps {
19
+ platform: NodeJS.Platform;
20
+ /** Started by launchd (`daemon run --managed`) and not hosted by an app. */
21
+ managedByLaunchd: boolean;
22
+ home: string;
23
+ /** ~/.hq/daemon: holds the marker and the helper's log. */
24
+ daemonRoot: string;
25
+ nodeBinary: string;
26
+ hqBinary: string;
27
+ env: NodeJS.ProcessEnv;
28
+ now?: () => number;
29
+ needsRefresh?: (home: string) => boolean;
30
+ spawnHelper?: (command: string, args: string[], logPath: string, env: NodeJS.ProcessEnv) => void;
31
+ log: (level: "info" | "warn", line: string) => void;
32
+ }
33
+ export declare function scheduleDaemonPlistRefresh(deps: PlistRefreshDeps): PlistRefreshOutcome;
34
+ //# sourceMappingURL=plist-refresh.d.ts.map
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Upgrade an older install's daemon LaunchAgent without a manual
3
+ * `hq daemon install`.
4
+ *
5
+ * Self-update and `hq update` restart the daemon through a control request and
6
+ * never rewrite its unit, so a Mac that only ever self-updated keeps the plist
7
+ * that runs through HQ.app and names its bundle, which launchd skips at login.
8
+ * When the managed daemon starts and finds that plist, it spawns a detached
9
+ * `hq daemon start`, which rewrites the plist and reloads it (see
10
+ * refreshDaemonPlist). The helper runs in its own session, outside the
11
+ * daemon's job, so the bootout that ends this daemon does not end the helper
12
+ * before it bootstraps the new definition.
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import { daemonPlistNeedsRefresh } from "./install.js";
18
+ import { ensurePrivateDir } from "./paths.js";
19
+ /** At most one helper per this long, so a rewrite that keeps failing cannot loop. */
20
+ export const PLIST_REFRESH_RETRY_MS = 60 * 60_000;
21
+ export const PLIST_REFRESH_MARKER = "plist-refresh.json";
22
+ function defaultSpawnHelper(command, args, logPath, env) {
23
+ const fd = fs.openSync(logPath, "a", 0o600);
24
+ try {
25
+ const child = spawn(command, args, { detached: true, stdio: ["ignore", fd, fd], env });
26
+ child.unref();
27
+ }
28
+ finally {
29
+ fs.closeSync(fd);
30
+ }
31
+ }
32
+ export function scheduleDaemonPlistRefresh(deps) {
33
+ if (deps.platform !== "darwin" || !deps.managedByLaunchd)
34
+ return "not-applicable";
35
+ if (!(deps.needsRefresh ?? daemonPlistNeedsRefresh)(deps.home))
36
+ return "current";
37
+ const now = (deps.now ?? Date.now)();
38
+ const marker = path.join(deps.daemonRoot, PLIST_REFRESH_MARKER);
39
+ try {
40
+ const last = JSON.parse(fs.readFileSync(marker, "utf8"));
41
+ if (typeof last.attemptedAt === "number" && now - last.attemptedAt < PLIST_REFRESH_RETRY_MS) {
42
+ deps.log("warn", "the daemon LaunchAgent still runs through HQ.app; a rewrite was tried within the last hour. Run: hq daemon start");
43
+ return "recent";
44
+ }
45
+ }
46
+ catch {
47
+ // No marker yet.
48
+ }
49
+ ensurePrivateDir(path.join(deps.daemonRoot, "logs"));
50
+ const tmp = `${marker}.${process.pid}.tmp`;
51
+ fs.writeFileSync(tmp, `${JSON.stringify({ attemptedAt: now })}\n`, { mode: 0o600 });
52
+ fs.renameSync(tmp, marker);
53
+ // launchd marks its jobs' processes with XPC_SERVICE_NAME; the helper is not the job.
54
+ const env = { ...deps.env };
55
+ delete env.XPC_SERVICE_NAME;
56
+ const logPath = path.join(deps.daemonRoot, "logs", "plist-refresh.log");
57
+ (deps.spawnHelper ?? defaultSpawnHelper)(deps.nodeBinary, [deps.hqBinary, "daemon", "start"], logPath, env);
58
+ deps.log("info", "the daemon LaunchAgent runs through HQ.app, which launchd skips at login; started `hq daemon start` to rewrite and reload it " +
59
+ `(log: ${logPath})`);
60
+ return "spawned";
61
+ }
62
+ //# sourceMappingURL=plist-refresh.js.map