@indigoai-us/hq-cli 5.272.0 → 5.273.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.273.0-alpha.0] — 2026-09-28
6
+
7
+ ### Added
8
+
9
+ - `hq daemon sync` reports runner ownership and health, starts an immediate sync pass, pauses or resumes sync, and sets per-company sync mode. The daemon waits for an existing desktop runner to release its lease before starting its own.
10
+
5
11
  ## [5.272.0] — 2026-09-28
6
12
 
7
13
  ### Added
@@ -9098,6 +9098,120 @@ export declare const COMMAND_CATALOG: readonly [{
9098
9098
  readonly description: "Print machine-readable JSON";
9099
9099
  }];
9100
9100
  readonly subcommands: readonly [];
9101
+ }, {
9102
+ readonly name: "sync";
9103
+ readonly description: "Inspect and control the daemon's company sync runner";
9104
+ readonly aliases: readonly [];
9105
+ readonly hidden: false;
9106
+ readonly usage: "[options] [command]";
9107
+ readonly arguments: readonly [];
9108
+ readonly options: readonly [];
9109
+ readonly subcommands: readonly [{
9110
+ readonly name: "status";
9111
+ readonly description: "Show sync ownership, heartbeat, last pass and log path";
9112
+ readonly aliases: readonly [];
9113
+ readonly hidden: false;
9114
+ readonly usage: "[options]";
9115
+ readonly arguments: readonly [];
9116
+ readonly options: readonly [{
9117
+ readonly flags: "--json";
9118
+ readonly description: "Print machine-readable JSON";
9119
+ }];
9120
+ readonly subcommands: readonly [];
9121
+ }, {
9122
+ readonly name: "now";
9123
+ readonly description: "Start a fresh sync pass immediately";
9124
+ readonly aliases: readonly [];
9125
+ readonly hidden: false;
9126
+ readonly usage: "[options]";
9127
+ readonly arguments: readonly [];
9128
+ readonly options: readonly [{
9129
+ readonly flags: "--json";
9130
+ readonly description: "Print machine-readable JSON";
9131
+ }];
9132
+ readonly subcommands: readonly [];
9133
+ }, {
9134
+ readonly name: "pause";
9135
+ readonly description: "Pause daemon sync until resumed";
9136
+ readonly aliases: readonly [];
9137
+ readonly hidden: false;
9138
+ readonly usage: "[options]";
9139
+ readonly arguments: readonly [];
9140
+ readonly options: readonly [{
9141
+ readonly flags: "--json";
9142
+ readonly description: "Print machine-readable JSON";
9143
+ }];
9144
+ readonly subcommands: readonly [];
9145
+ }, {
9146
+ readonly name: "resume";
9147
+ readonly description: "Resume daemon sync after a pause";
9148
+ readonly aliases: readonly [];
9149
+ readonly hidden: false;
9150
+ readonly usage: "[options]";
9151
+ readonly arguments: readonly [];
9152
+ readonly options: readonly [{
9153
+ readonly flags: "--json";
9154
+ readonly description: "Print machine-readable JSON";
9155
+ }];
9156
+ readonly subcommands: readonly [];
9157
+ }, {
9158
+ readonly name: "take-ownership";
9159
+ readonly description: "Persist daemon ownership of desktop sync and reconcile";
9160
+ readonly aliases: readonly [];
9161
+ readonly hidden: false;
9162
+ readonly usage: "[options]";
9163
+ readonly arguments: readonly [];
9164
+ readonly options: readonly [{
9165
+ readonly flags: "--json";
9166
+ readonly description: "Print machine-readable JSON";
9167
+ }];
9168
+ readonly subcommands: readonly [];
9169
+ }, {
9170
+ readonly name: "mode";
9171
+ readonly description: "Read or change a company's sync footprint";
9172
+ readonly aliases: readonly [];
9173
+ readonly hidden: false;
9174
+ readonly usage: "[options] [command]";
9175
+ readonly arguments: readonly [];
9176
+ readonly options: readonly [];
9177
+ readonly subcommands: readonly [{
9178
+ readonly name: "get";
9179
+ readonly description: "Read a company's current all/shared sync mode";
9180
+ readonly aliases: readonly [];
9181
+ readonly hidden: false;
9182
+ readonly usage: "[options] <company>";
9183
+ readonly arguments: readonly [{
9184
+ readonly name: "company";
9185
+ readonly required: true;
9186
+ readonly variadic: false;
9187
+ }];
9188
+ readonly options: readonly [{
9189
+ readonly flags: "--json";
9190
+ readonly description: "Print machine-readable JSON";
9191
+ }];
9192
+ readonly subcommands: readonly [];
9193
+ }, {
9194
+ readonly name: "set";
9195
+ readonly description: "Set a company's sync mode to all or shared";
9196
+ readonly aliases: readonly [];
9197
+ readonly hidden: false;
9198
+ readonly usage: "[options] <company> <mode>";
9199
+ readonly arguments: readonly [{
9200
+ readonly name: "company";
9201
+ readonly required: true;
9202
+ readonly variadic: false;
9203
+ }, {
9204
+ readonly name: "mode";
9205
+ readonly required: true;
9206
+ readonly variadic: false;
9207
+ }];
9208
+ readonly options: readonly [{
9209
+ readonly flags: "--json";
9210
+ readonly description: "Print machine-readable JSON";
9211
+ }];
9212
+ readonly subcommands: readonly [];
9213
+ }];
9214
+ }];
9101
9215
  }, {
9102
9216
  readonly name: "logs";
9103
9217
  readonly description: "Print the supervisor log, or one service's log";
@@ -11767,6 +11767,150 @@ export const COMMAND_CATALOG = [
11767
11767
  ],
11768
11768
  "subcommands": []
11769
11769
  },
11770
+ {
11771
+ "name": "sync",
11772
+ "description": "Inspect and control the daemon's company sync runner",
11773
+ "aliases": [],
11774
+ "hidden": false,
11775
+ "usage": "[options] [command]",
11776
+ "arguments": [],
11777
+ "options": [],
11778
+ "subcommands": [
11779
+ {
11780
+ "name": "status",
11781
+ "description": "Show sync ownership, heartbeat, last pass and log path",
11782
+ "aliases": [],
11783
+ "hidden": false,
11784
+ "usage": "[options]",
11785
+ "arguments": [],
11786
+ "options": [
11787
+ {
11788
+ "flags": "--json",
11789
+ "description": "Print machine-readable JSON"
11790
+ }
11791
+ ],
11792
+ "subcommands": []
11793
+ },
11794
+ {
11795
+ "name": "now",
11796
+ "description": "Start a fresh sync pass immediately",
11797
+ "aliases": [],
11798
+ "hidden": false,
11799
+ "usage": "[options]",
11800
+ "arguments": [],
11801
+ "options": [
11802
+ {
11803
+ "flags": "--json",
11804
+ "description": "Print machine-readable JSON"
11805
+ }
11806
+ ],
11807
+ "subcommands": []
11808
+ },
11809
+ {
11810
+ "name": "pause",
11811
+ "description": "Pause daemon sync until resumed",
11812
+ "aliases": [],
11813
+ "hidden": false,
11814
+ "usage": "[options]",
11815
+ "arguments": [],
11816
+ "options": [
11817
+ {
11818
+ "flags": "--json",
11819
+ "description": "Print machine-readable JSON"
11820
+ }
11821
+ ],
11822
+ "subcommands": []
11823
+ },
11824
+ {
11825
+ "name": "resume",
11826
+ "description": "Resume daemon sync after a pause",
11827
+ "aliases": [],
11828
+ "hidden": false,
11829
+ "usage": "[options]",
11830
+ "arguments": [],
11831
+ "options": [
11832
+ {
11833
+ "flags": "--json",
11834
+ "description": "Print machine-readable JSON"
11835
+ }
11836
+ ],
11837
+ "subcommands": []
11838
+ },
11839
+ {
11840
+ "name": "take-ownership",
11841
+ "description": "Persist daemon ownership of desktop sync and reconcile",
11842
+ "aliases": [],
11843
+ "hidden": false,
11844
+ "usage": "[options]",
11845
+ "arguments": [],
11846
+ "options": [
11847
+ {
11848
+ "flags": "--json",
11849
+ "description": "Print machine-readable JSON"
11850
+ }
11851
+ ],
11852
+ "subcommands": []
11853
+ },
11854
+ {
11855
+ "name": "mode",
11856
+ "description": "Read or change a company's sync footprint",
11857
+ "aliases": [],
11858
+ "hidden": false,
11859
+ "usage": "[options] [command]",
11860
+ "arguments": [],
11861
+ "options": [],
11862
+ "subcommands": [
11863
+ {
11864
+ "name": "get",
11865
+ "description": "Read a company's current all/shared sync mode",
11866
+ "aliases": [],
11867
+ "hidden": false,
11868
+ "usage": "[options] <company>",
11869
+ "arguments": [
11870
+ {
11871
+ "name": "company",
11872
+ "required": true,
11873
+ "variadic": false
11874
+ }
11875
+ ],
11876
+ "options": [
11877
+ {
11878
+ "flags": "--json",
11879
+ "description": "Print machine-readable JSON"
11880
+ }
11881
+ ],
11882
+ "subcommands": []
11883
+ },
11884
+ {
11885
+ "name": "set",
11886
+ "description": "Set a company's sync mode to all or shared",
11887
+ "aliases": [],
11888
+ "hidden": false,
11889
+ "usage": "[options] <company> <mode>",
11890
+ "arguments": [
11891
+ {
11892
+ "name": "company",
11893
+ "required": true,
11894
+ "variadic": false
11895
+ },
11896
+ {
11897
+ "name": "mode",
11898
+ "required": true,
11899
+ "variadic": false
11900
+ }
11901
+ ],
11902
+ "options": [
11903
+ {
11904
+ "flags": "--json",
11905
+ "description": "Print machine-readable JSON"
11906
+ }
11907
+ ],
11908
+ "subcommands": []
11909
+ }
11910
+ ]
11911
+ }
11912
+ ]
11913
+ },
11770
11914
  {
11771
11915
  "name": "logs",
11772
11916
  "description": "Print the supervisor log, or one service's log",
@@ -16,6 +16,7 @@
16
16
  import type { Command } from "commander";
17
17
  import { type LegacyUnit } from "../lib/daemon/legacy.js";
18
18
  import type { HostApp } from "../lib/daemon/supervisor.js";
19
+ import { type SyncModeVaultClient } from "./sync-mode.js";
19
20
  import type { RunCommand } from "../lib/service-manager/launchd.js";
20
21
  export interface DaemonCommandDeps {
21
22
  env: NodeJS.ProcessEnv;
@@ -38,6 +39,8 @@ export interface DaemonCommandDeps {
38
39
  userHome: (user: string) => string;
39
40
  /** The pid is a live `hq daemon run`. */
40
41
  daemonAlive: (pid: number) => boolean;
42
+ /** Test seam for the company sync-mode commands. */
43
+ syncModeClient?: () => Promise<SyncModeVaultClient>;
41
44
  windowsUser?: string;
42
45
  windowsHeadlessLauncher?: string;
43
46
  systemUnitDir?: string;
@@ -18,7 +18,7 @@ import * as os from "node:os";
18
18
  import * as path from "node:path";
19
19
  import { major as semverMajor, valid as validSemver } from "semver";
20
20
  import { DEFAULT_SERVICES, SERVICE_DESCRIPTIONS, enabledServices, hqCloudUnits } from "../lib/daemon/catalog.js";
21
- import { applyServiceToggle, readDaemonConfig, writeDaemonConfig } from "../lib/daemon/config.js";
21
+ import { applyServiceToggle, effectiveSyncOwner, readDaemonConfig, writeDaemonConfig } from "../lib/daemon/config.js";
22
22
  import { daemonUnitInstalled } from "../lib/daemon/handoff.js";
23
23
  import { adoptLegacyUnits, readAdoptedUnits, restoreLegacyUnits } from "../lib/daemon/migrate.js";
24
24
  import { ROOT_REFUSAL, SYSTEM_UNIT_DIR, daemonRootRefusal, installDaemonUnit, installScopeFor, uninstallDaemonUnit, } from "../lib/daemon/install.js";
@@ -27,7 +27,10 @@ import { isDaemonProcess, runningDaemonPid } from "../lib/daemon/lock.js";
27
27
  import { bundledHqCloud } from "../lib/daemon/hq-cloud.js";
28
28
  import { MACHINE_TYPE_MARKER_PATH, detectMachineType, homeFromPasswd, } from "../lib/daemon/machine-type.js";
29
29
  import { daemonPaths } from "../lib/daemon/paths.js";
30
+ import { processCommandLine } from "../lib/daemon/lock.js";
30
31
  import { writeControlRequest } from "../lib/daemon/requests.js";
32
+ import { isSyncRunnerCommandLine, readWatchRunnerOwnerStatus, watchRunnerOwnerStatusPath } from "../lib/daemon/sync-runner.js";
33
+ import { getSyncModeForCompany, setSyncMode } from "./sync-mode.js";
31
34
  import { resolveDefaultHqRoot } from "../utils/cognito-session.js";
32
35
  class DaemonCommandError extends Error {
33
36
  }
@@ -96,6 +99,7 @@ async function loadDefaults(over) {
96
99
  windowsUser: over.windowsUser ?? (platform === "win32" ? mesh.resolveWindowsUser(env) : ""),
97
100
  windowsHeadlessLauncher: over.windowsHeadlessLauncher ?? (platform === "win32" ? mesh.resolveWindowsHeadlessLauncher(env) : undefined),
98
101
  systemUnitDir: over.systemUnitDir ?? SYSTEM_UNIT_DIR,
102
+ syncModeClient: over.syncModeClient,
99
103
  };
100
104
  }
101
105
  function detect(d, flag, config) {
@@ -372,6 +376,135 @@ function queueRequest(d, request, what) {
372
376
  const running = runningDaemonPid(paths.lockPath, d.daemonAlive) !== null;
373
377
  d.out(running ? `Asked the daemon to ${what}.` : `Queued "${what}"; the daemon is not running, so it applies when it starts.`);
374
378
  }
379
+ function daemonSyncModeClient(d) {
380
+ if (d.syncModeClient)
381
+ return d.syncModeClient();
382
+ return (async () => {
383
+ const [{ VaultClient }, { buildVaultConfig, ensureCognitoToken }] = await Promise.all([
384
+ import("@indigoai-us/hq-cloud"),
385
+ import("../utils/cognito-session.js"),
386
+ ]);
387
+ const token = await ensureCognitoToken();
388
+ return new VaultClient(buildVaultConfig(token));
389
+ })();
390
+ }
391
+ function daemonSyncStatus(d) {
392
+ const host = currentHost(d);
393
+ const { paths } = host;
394
+ const config = readDaemonConfig(paths.configPath);
395
+ const daemonPid = runningDaemonPid(paths.lockPath, d.daemonAlive);
396
+ const daemonState = daemonPid === null ? null : readState(paths);
397
+ const syncUnit = daemonState?.units.find((unit) => unit.id === "sync");
398
+ const hqRoot = config.hqRoot ?? d.env.HQ_ROOT?.trim();
399
+ let ownerStatusCandidate = null;
400
+ if (hqRoot) {
401
+ try {
402
+ ownerStatusCandidate = readWatchRunnerOwnerStatus(watchRunnerOwnerStatusPath(hqRoot, d.home, d.env));
403
+ }
404
+ catch (error) {
405
+ const detail = error instanceof Error ? error.message : String(error);
406
+ d.err(`Could not read sync runner owner status; treating it as unavailable: ${detail}`);
407
+ }
408
+ }
409
+ const ownerCommandLine = ownerStatusCandidate
410
+ ? processCommandLine(ownerStatusCandidate.pid, { platform: d.platform, run: d.run })
411
+ : null;
412
+ const ownerStatus = ownerCommandLine && isSyncRunnerCommandLine(ownerCommandLine)
413
+ ? ownerStatusCandidate
414
+ : null;
415
+ return {
416
+ running: syncUnit?.status === "running",
417
+ paused: config.syncPaused === true,
418
+ syncOwner: effectiveSyncOwner(config, host.detected.type),
419
+ owner: ownerStatus?.owner ?? (syncUnit?.status === "running" ? "hq-daemon" : null),
420
+ runnerPid: ownerStatus?.pid ?? syncUnit?.pid ?? null,
421
+ hqCloudVersion: ownerStatus?.version ?? null,
422
+ lastHeartbeat: syncUnit?.lastHeartbeatAt ?? ownerStatus?.heartbeatAt ?? null,
423
+ lastPassResult: syncUnit?.lastPassResult ?? null,
424
+ unitStatus: syncUnit?.status ?? "not-running",
425
+ reason: syncUnit?.reason ?? null,
426
+ logPath: paths.unitLogPath("sync"),
427
+ };
428
+ }
429
+ async function runDaemonSyncStatus(d, opts) {
430
+ const status = daemonSyncStatus(d);
431
+ if (opts.json) {
432
+ d.out(JSON.stringify(status, null, 2));
433
+ return;
434
+ }
435
+ d.out(`Sync ${status.running ? "running" : status.unitStatus}${status.paused ? " (paused)" : ""}.`);
436
+ d.out(`Configured sync owner: ${status.syncOwner}.`);
437
+ d.out(`Owner: ${status.owner ?? "none"}; runner pid: ${status.runnerPid ?? "none"}; hq-cloud: ${status.hqCloudVersion ?? "unknown"}.`);
438
+ if (status.reason)
439
+ d.out(`Reason: ${status.reason}`);
440
+ d.out(`Log: ${status.logPath}`);
441
+ }
442
+ function queueDaemonSyncControl(d, request) {
443
+ const host = currentHost(d);
444
+ writeControlRequest(host.paths.requestsDir, request);
445
+ handOver(d, host);
446
+ return runningDaemonPid(host.paths.lockPath, d.daemonAlive) !== null;
447
+ }
448
+ async function runDaemonSyncNow(d, opts) {
449
+ const host = currentHost(d);
450
+ const config = readDaemonConfig(host.paths.configPath);
451
+ if (config.syncPaused)
452
+ throw new DaemonCommandError("sync is paused; run `hq daemon sync resume` first");
453
+ if (!enabledServices(host.detected.type, config).includes("sync")) {
454
+ throw new DaemonCommandError("sync is disabled for this machine; run `hq daemon enable sync` first");
455
+ }
456
+ const running = queueDaemonSyncControl(d, { action: "restart", unit: "sync" });
457
+ const result = { accepted: true, action: "restart-sync-runner", daemonRunning: running };
458
+ d.out(opts.json ? JSON.stringify(result, null, 2) : running ? "Asked the daemon to start a fresh sync pass." : "Queued a fresh sync pass for the next daemon start.");
459
+ }
460
+ async function runDaemonSyncPause(d, paused, opts) {
461
+ const host = currentHost(d);
462
+ const config = readDaemonConfig(host.paths.configPath);
463
+ if (config.syncPaused !== paused)
464
+ writeDaemonConfig(host.paths.configPath, { ...config, syncPaused: paused });
465
+ const daemonRunning = queueDaemonSyncControl(d, { action: "reconcile" });
466
+ const result = { paused, persisted: true, daemonRunning };
467
+ d.out(opts.json ? JSON.stringify(result, null, 2) : `Sync ${paused ? "paused" : "resumed"}${daemonRunning ? "" : "; the setting will apply when the daemon starts"}.`);
468
+ }
469
+ async function runDaemonSyncTakeOwnership(d, opts) {
470
+ const host = currentHost(d);
471
+ const config = readDaemonConfig(host.paths.configPath);
472
+ if (config.syncOwner !== "daemon")
473
+ writeDaemonConfig(host.paths.configPath, { ...config, syncOwner: "daemon" });
474
+ const daemonRunning = queueDaemonSyncControl(d, { action: "reconcile" });
475
+ const result = { syncOwner: "daemon", persisted: true, daemonRunning };
476
+ d.out(opts.json ? JSON.stringify(result, null, 2) : `Daemon sync ownership persisted${daemonRunning ? "; asked the daemon to reconcile" : "; it applies when the daemon starts"}.`);
477
+ }
478
+ async function runDaemonSyncModeGet(d, companySlug, opts) {
479
+ const result = await getSyncModeForCompany({ companySlug, vaultClient: await daemonSyncModeClient(d) });
480
+ const value = {
481
+ companySlug: result.companySlug,
482
+ membershipId: result.membershipId,
483
+ mode: result.config.syncMode,
484
+ isDefault: result.config.isDefault,
485
+ ...(result.config.updatedAt ? { updatedAt: result.config.updatedAt } : {}),
486
+ };
487
+ d.out(opts.json ? JSON.stringify(value, null, 2) : `${value.companySlug}: ${value.mode}${value.isDefault ? " (default)" : ""}`);
488
+ }
489
+ async function runDaemonSyncModeSet(d, companySlug, mode, opts) {
490
+ if (mode !== "all" && mode !== "shared") {
491
+ throw new DaemonCommandError(`Invalid sync mode '${mode}'. Use all or shared.`);
492
+ }
493
+ const result = await setSyncMode({
494
+ mode,
495
+ companySlug,
496
+ vaultClient: await daemonSyncModeClient(d),
497
+ });
498
+ const value = {
499
+ companySlug: result.companySlug,
500
+ membershipId: result.membershipId,
501
+ previousMode: result.previousMode,
502
+ mode: result.newMode,
503
+ isDefault: result.newConfig.isDefault,
504
+ ...(result.newConfig.updatedAt ? { updatedAt: result.newConfig.updatedAt } : {}),
505
+ };
506
+ d.out(opts.json ? JSON.stringify(value, null, 2) : `${value.companySlug}: ${value.previousMode} → ${value.mode}`);
507
+ }
375
508
  async function runToggle(d, action, services) {
376
509
  const host = currentHost(d);
377
510
  const { paths } = host;
@@ -603,6 +736,45 @@ export function registerDaemonCommand(program, over = {}) {
603
736
  .description("Show the machine type and each service's state")
604
737
  .option("--json", "Print machine-readable JSON")
605
738
  .action(wrap((d, opts) => runStatus(d, opts)));
739
+ const sync = daemon
740
+ .command("sync")
741
+ .description("Inspect and control the daemon's company sync runner");
742
+ sync
743
+ .command("status")
744
+ .description("Show sync ownership, heartbeat, last pass and log path")
745
+ .option("--json", "Print machine-readable JSON")
746
+ .action(wrap((d, opts) => runDaemonSyncStatus(d, opts)));
747
+ sync
748
+ .command("now")
749
+ .description("Start a fresh sync pass immediately")
750
+ .option("--json", "Print machine-readable JSON")
751
+ .action(wrap((d, opts) => runDaemonSyncNow(d, opts)));
752
+ sync
753
+ .command("pause")
754
+ .description("Pause daemon sync until resumed")
755
+ .option("--json", "Print machine-readable JSON")
756
+ .action(wrap((d, opts) => runDaemonSyncPause(d, true, opts)));
757
+ sync
758
+ .command("resume")
759
+ .description("Resume daemon sync after a pause")
760
+ .option("--json", "Print machine-readable JSON")
761
+ .action(wrap((d, opts) => runDaemonSyncPause(d, false, opts)));
762
+ sync
763
+ .command("take-ownership")
764
+ .description("Persist daemon ownership of desktop sync and reconcile")
765
+ .option("--json", "Print machine-readable JSON")
766
+ .action(wrap((d, opts) => runDaemonSyncTakeOwnership(d, opts)));
767
+ const mode = sync.command("mode").description("Read or change a company's sync footprint");
768
+ mode
769
+ .command("get <company>")
770
+ .description("Read a company's current all/shared sync mode")
771
+ .option("--json", "Print machine-readable JSON")
772
+ .action(wrap((d, company, opts) => runDaemonSyncModeGet(d, company, opts)));
773
+ mode
774
+ .command("set <company> <mode>")
775
+ .description("Set a company's sync mode to all or shared")
776
+ .option("--json", "Print machine-readable JSON")
777
+ .action(wrap((d, company, mode, opts) => runDaemonSyncModeSet(d, company, mode, opts)));
606
778
  daemon
607
779
  .command("logs [unit]")
608
780
  .description("Print the supervisor log, or one service's log")
@@ -70,6 +70,15 @@ export interface SetSyncModeResult {
70
70
  export interface ShowSyncModesOptions {
71
71
  vaultClient: SyncModeVaultClient;
72
72
  }
73
+ export interface GetSyncModeOptions {
74
+ companySlug: string;
75
+ vaultClient: SyncModeVaultClient;
76
+ }
77
+ export interface GetSyncModeResult {
78
+ membershipId: string;
79
+ companySlug: string;
80
+ config: MembershipSyncConfig;
81
+ }
73
82
  export interface ShowSyncModesRow {
74
83
  companySlug: string;
75
84
  companyName?: string;
@@ -100,6 +109,8 @@ export declare function setSyncMode(options: SetSyncModeOptions): Promise<SetSyn
100
109
  * stable table output.
101
110
  */
102
111
  export declare function showSyncModes(options: ShowSyncModesOptions): Promise<ShowSyncModesRow[]>;
112
+ /** Read one company's effective sync mode without hiding API read failures. */
113
+ export declare function getSyncModeForCompany(options: GetSyncModeOptions): Promise<GetSyncModeResult>;
103
114
  /**
104
115
  * Render the `--show` table as plain text. No external dep — hq-cli doesn't
105
116
  * use cli-table3, so we hand-format columns to match the existing chalk +
@@ -163,6 +163,36 @@ export async function showSyncModes(options) {
163
163
  rows.sort((a, b) => a.companySlug.localeCompare(b.companySlug));
164
164
  return rows;
165
165
  }
166
+ /** Read one company's effective sync mode without hiding API read failures. */
167
+ export async function getSyncModeForCompany(options) {
168
+ const { companySlug, vaultClient } = options;
169
+ const memberships = await vaultClient.listMyMemberships();
170
+ const enriched = await Promise.all(memberships.map(async (membership) => {
171
+ try {
172
+ const entity = await vaultClient.entity.get(membership.companyUid);
173
+ return { ok: true, membership, slug: entity.slug };
174
+ }
175
+ catch (error) {
176
+ return { ok: false, membership, error };
177
+ }
178
+ }));
179
+ const match = enriched.find((row) => row.ok && row.slug === companySlug);
180
+ if (!match) {
181
+ const failedLookup = enriched.find((row) => !row.ok);
182
+ if (failedLookup && !failedLookup.ok)
183
+ throw failedLookup.error;
184
+ throw new Error(`No membership found for company '${companySlug}'.`);
185
+ }
186
+ for (const failedLookup of enriched) {
187
+ if (!failedLookup.ok) {
188
+ const detail = failedLookup.error instanceof Error ? failedLookup.error.message : String(failedLookup.error);
189
+ console.error(`getSyncModeForCompany: entity lookup failed for membership ${failedLookup.membership.membershipKey} while resolving '${companySlug}': ${detail}`);
190
+ }
191
+ }
192
+ const membershipId = match.membership.membershipKey;
193
+ const config = await vaultClient.getMembershipSyncConfig(membershipId);
194
+ return { membershipId, companySlug, config };
195
+ }
166
196
  // ── Table rendering (used by CLI action, separable for tests) ──────────────
167
197
  /**
168
198
  * Render the `--show` table as plain text. No external dep — hq-cli doesn't
@@ -10,6 +10,8 @@
10
10
  * them instead of silently dropping them.
11
11
  */
12
12
  import type { MachineType } from "./machine-type.js";
13
+ import type { SyncOwner } from "./config.js";
14
+ import { type SyncRunnerProcess } from "./sync-runner.js";
13
15
  export declare const SERVICE_NAMES: readonly ["sync", "mesh", "heartbeat", "inbox", "bots", "remote-control", "codex-remote-control", "session-host", "lanes-reconcile", "search-index", "jobs-reconcile", "updater", "housekeeping"];
14
16
  export type ServiceName = (typeof SERVICE_NAMES)[number];
15
17
  export declare function isServiceName(value: unknown): value is ServiceName;
@@ -51,6 +53,8 @@ export interface UnitPlan {
51
53
  restart: "always" | "on-failure";
52
54
  intervalSec?: number;
53
55
  initialDelaySec?: number;
56
+ /** Kill and restart this child after silence on its liveness stream. */
57
+ heartbeatTimeoutMs?: number;
54
58
  command: UnitCommand | null;
55
59
  env: Record<string, string>;
56
60
  blocked?: UnitBlock;
@@ -68,11 +72,23 @@ export interface PlanContext {
68
72
  sessionHostRunnerPath: string | null;
69
73
  /** Environment retained from legacy outpost sync systemd drop-ins. */
70
74
  legacySyncEnv: Record<string, string>;
75
+ /** Keep the local sync runner stopped until the operator resumes it. */
76
+ syncPaused: boolean;
77
+ /** Persisted/effective owner selected for this machine. */
78
+ syncOwner: SyncOwner;
79
+ /** A legacy desktop app or sync unit is still managing sync. */
80
+ desktopAppManagesSync: boolean;
81
+ /** Full process list, or null when the daemon could not read it. */
82
+ syncRunnerProcesses: readonly SyncRunnerProcess[] | null;
83
+ /** PID recorded in the watch-owner status, when it is readable. */
84
+ syncRunnerOwnerPid: number | null;
85
+ /** Sync runner children already started by this daemon before their lease is written. */
86
+ daemonSyncRunnerPids?: ReadonlySet<number>;
87
+ /** The protocol-event heartbeat watchdog timeout for the sync runner. */
88
+ syncHeartbeatTimeoutMs: number;
71
89
  signedIn: boolean;
72
90
  /** ~/.hq-agent/kit.json exists. */
73
91
  kitConfigured: boolean;
74
- /** The HQ desktop app runs its own sync on this machine. */
75
- desktopAppManagesSync: boolean;
76
92
  bots: readonly string[];
77
93
  /** Name of a pre-daemon unit that still runs this unit id, or null. */
78
94
  legacyOwner: (unitId: string) => string | null;
@@ -9,6 +9,7 @@
9
9
  * with no entrypoint yet stay in the catalog as `unavailable` so status shows
10
10
  * them instead of silently dropping them.
11
11
  */
12
+ import { SYNC_RUNNER_OWNER, syncRunnerUnleasedBlockReason } from "./sync-runner.js";
12
13
  export const SERVICE_NAMES = [
13
14
  "sync",
14
15
  "mesh",
@@ -127,14 +128,29 @@ function planService(service, ctx) {
127
128
  command: {
128
129
  type: "node",
129
130
  script: ctx.syncRunnerPath,
130
- args: ["--companies", "--direction", "both", "--on-conflict", "keep", "--hq-root", ctx.hqRoot ?? "", "--watch", "--event-push"],
131
+ args: ["--companies", "--direction", "both", "--on-conflict", "keep", "--hq-root", ctx.hqRoot ?? "", "--watch", "--event-push", "--owner", SYNC_RUNNER_OWNER],
131
132
  },
132
133
  env: { ...ctx.legacySyncEnv },
134
+ heartbeatTimeoutMs: ctx.syncHeartbeatTimeoutMs,
133
135
  });
136
+ let ownerBlock;
137
+ if (ctx.machineType === "desktop" && ctx.syncOwner === "desktop-app" && ctx.desktopAppManagesSync) {
138
+ ownerBlock = "the desktop app is still managing sync (run `hq daemon sync take-ownership` after it stops its runner)";
139
+ }
140
+ else if (ctx.machineType === "desktop" &&
141
+ ctx.syncOwner === "daemon" &&
142
+ !ctx.syncPaused &&
143
+ ctx.hqRoot &&
144
+ ctx.signedIn) {
145
+ ownerBlock = ctx.syncRunnerProcesses === null
146
+ ? "could not check for an existing sync runner; waiting to avoid starting a duplicate"
147
+ : syncRunnerUnleasedBlockReason(ctx.hqRoot, ctx.syncRunnerProcesses, ctx.syncRunnerOwnerPid, ctx.platform, ctx.daemonSyncRunnerPids);
148
+ }
134
149
  unit.blocked = firstBlock([
135
- [ctx.desktopAppManagesSync, "the HQ desktop app is handling sync on this machine"],
150
+ [ctx.syncPaused, "sync is paused (run `hq daemon sync resume`)"],
136
151
  [!ctx.hqRoot, NO_HQ_ROOT],
137
152
  [!ctx.signedIn, NOT_SIGNED_IN],
153
+ [!!ownerBlock, ownerBlock ?? ""],
138
154
  ]);
139
155
  return [unit];
140
156
  }