@indigoai-us/hq-cli 5.345.175 → 5.345.177

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 (56) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/command-catalog.generated.d.ts +65 -0
  3. package/dist/command-catalog.generated.js +80 -0
  4. package/dist/commands/bot.d.ts +28 -2
  5. package/dist/commands/bot.js +29 -6
  6. package/dist/commands/daemon.js +16 -0
  7. package/dist/commands/lanes-loop.d.ts +4 -1
  8. package/dist/commands/lanes-loop.js +23 -4
  9. package/dist/commands/lanes-shared.d.ts +5 -0
  10. package/dist/commands/lanes.d.ts +5 -7
  11. package/dist/commands/lanes.js +65 -5
  12. package/dist/commands/sync-core.d.ts +17 -0
  13. package/dist/commands/sync-core.js +46 -5
  14. package/dist/commands/sync-manifest.d.ts +6 -0
  15. package/dist/commands/sync-manifest.js +13 -0
  16. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +448 -226
  17. package/dist/hq-cloud-sync-runner/package.json +1 -1
  18. package/dist/lib/bot/cli-update.d.ts +36 -0
  19. package/dist/lib/bot/cli-update.js +160 -42
  20. package/dist/lib/bot/run.d.ts +21 -0
  21. package/dist/lib/bot/run.js +127 -10
  22. package/dist/lib/bot/runtime/claude.d.ts +7 -1
  23. package/dist/lib/bot/runtime/claude.js +29 -0
  24. package/dist/lib/bot/runtime/codex.d.ts +5 -0
  25. package/dist/lib/bot/runtime/codex.js +13 -0
  26. package/dist/lib/bot/runtime/types.d.ts +10 -0
  27. package/dist/lib/bot/runtime-sign-in-check.d.ts +32 -0
  28. package/dist/lib/bot/runtime-sign-in-check.js +53 -0
  29. package/dist/lib/bot/runtime-sign-in.d.ts +32 -3
  30. package/dist/lib/bot/runtime-sign-in.js +17 -2
  31. package/dist/lib/bot/status.d.ts +25 -1
  32. package/dist/lib/bot/status.js +28 -0
  33. package/dist/lib/daemon/catalog.d.ts +4 -0
  34. package/dist/lib/daemon/catalog.js +47 -19
  35. package/dist/lib/daemon/run.js +1 -0
  36. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  37. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  38. package/dist/lib/lanes/loop-disposition.d.ts +44 -0
  39. package/dist/lib/lanes/loop-disposition.js +302 -0
  40. package/dist/lib/lanes/loop.d.ts +16 -3
  41. package/dist/lib/lanes/loop.js +167 -60
  42. package/dist/lib/lanes/reconcile.d.ts +5 -0
  43. package/dist/lib/lanes/reconcile.js +79 -4
  44. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +3 -0
  45. package/dist/lib/mesh/live/daemon/work-subscribe.js +41 -12
  46. package/dist/lib/sync-core/engine-home.d.ts +11 -4
  47. package/dist/lib/sync-core/engine-home.js +22 -21
  48. package/dist/lib/sync-core/manifest-scopes.d.ts +15 -0
  49. package/dist/lib/sync-core/manifest-scopes.js +26 -0
  50. package/dist/lib/sync-core/reconcile.d.ts +2 -2
  51. package/dist/lib/sync-core/reconcile.js +70 -46
  52. package/dist/lib/sync-core/release.d.ts +2 -2
  53. package/dist/lib/sync-core/release.js +31 -6
  54. package/dist/lib/sync-core/rollout-state.d.ts +6 -3
  55. package/dist/lib/sync-core/rollout-state.js +26 -22
  56. package/package.json +8 -8
@@ -90,5 +90,10 @@ export declare function bundledCodexBin(homeDir?: string, isExecutable?: IsExecu
90
90
  * usual "codex is not installed or not on PATH" error.
91
91
  */
92
92
  export declare function resolveCodexBinary(env: NodeJS.ProcessEnv, homeDir?: string, platform?: NodeJS.Platform, cwd?: string, isExecutable?: IsExecutableFile): string;
93
+ /**
94
+ * `codex login status` prints "Logged in using ChatGPT" (exit 0) or "Not
95
+ * logged in" (exit 1). Null when it said neither.
96
+ */
97
+ export declare function parseCodexLoginStatus(result: SpawnResult): boolean | null;
93
98
  export declare function createCodexRuntime(): BotRuntime;
94
99
  //# sourceMappingURL=codex.d.ts.map
@@ -270,11 +270,24 @@ export function resolveCodexBinary(env, homeDir, platform = process.platform, cw
270
270
  // `||`, not `??`: an empty HOME must not turn ~/Applications into a relative path.
271
271
  return bundledCodexBin(homeDir || env.HOME || os.homedir(), isExecutable) ?? "codex";
272
272
  }
273
+ /**
274
+ * `codex login status` prints "Logged in using ChatGPT" (exit 0) or "Not
275
+ * logged in" (exit 1). Null when it said neither.
276
+ */
277
+ export function parseCodexLoginStatus(result) {
278
+ const text = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
279
+ if (/\bnot logged in\b/i.test(text))
280
+ return false;
281
+ if (/\blogged in\b/i.test(text))
282
+ return true;
283
+ return null;
284
+ }
273
285
  export function createCodexRuntime() {
274
286
  return {
275
287
  id: "codex",
276
288
  binary: "codex",
277
289
  resolveBinary: (env, cwd, platform) => resolveCodexBinary(env, undefined, platform ?? process.platform, cwd),
290
+ signInStatus: { args: ["login", "status"], parse: parseCodexLoginStatus },
278
291
  buildArgs(input, scratch) {
279
292
  const promptArg = input.platform === "win32" ? "-" : codexPrompt(input);
280
293
  const common = ["--json", "--skip-git-repo-check", "-o", scratch.outputFile];
@@ -202,6 +202,16 @@ export interface BotRuntime {
202
202
  requestedModel: string | undefined;
203
203
  run: (args: string[]) => Promise<SpawnResult>;
204
204
  }): Promise<string[]>;
205
+ /**
206
+ * Optional: the tool's own "am I signed in?" command (`claude auth status
207
+ * --json`, `codex login status`), run in the bot's environment before a turn
208
+ * and at start. `parse` reads its answer: true/false, or null when the
209
+ * output does not say. Pure; needs no network.
210
+ */
211
+ signInStatus?: {
212
+ args: string[];
213
+ parse(result: SpawnResult): boolean | null;
214
+ };
205
215
  }
206
216
  /**
207
217
  * Turns have no time limit: a turn runs until the model process exits (the
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The bot's own look at its coding tool's sign-in, in the bot's environment.
3
+ *
4
+ * The owner's terminal and the bot are different processes with different
5
+ * environments: a bot started by launchd has none of the shell's variables,
6
+ * so Claude Code can be signed in for the terminal and signed out for the
7
+ * bot. The HQ app's own preflight runs with the app's environment and cannot
8
+ * see this either. Only the bot can, so it asks the tool itself (`claude auth
9
+ * status --json`, `codex login status`) with the exact environment its turns
10
+ * use, at start and before each turn, and reports the answer in status.json.
11
+ * The app reads it through `hq bot status --json` / `hq bot list --json`
12
+ * before it sends a brief job.
13
+ */
14
+ import type { BotRuntime, SpawnLike } from "./runtime/types.js";
15
+ import { type RuntimeSignInCheck } from "./runtime-sign-in.js";
16
+ export interface SignInCheckOptions {
17
+ runtime: BotRuntime;
18
+ /** The environment the bot's turns spawn the tool with. */
19
+ env: NodeJS.ProcessEnv;
20
+ cwd: string;
21
+ spawn?: SpawnLike;
22
+ timeoutMs?: number;
23
+ now?: () => Date;
24
+ platform?: NodeJS.Platform;
25
+ }
26
+ /**
27
+ * Ask the tool whether it is signed in. Never throws: a tool that cannot be
28
+ * asked (not installed, hung, no status command) answers `signedIn: null`,
29
+ * and the turn then runs as before and reports its own failure.
30
+ */
31
+ export declare function checkRuntimeSignIn(opts: SignInCheckOptions): Promise<RuntimeSignInCheck>;
32
+ //# sourceMappingURL=runtime-sign-in-check.d.ts.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The bot's own look at its coding tool's sign-in, in the bot's environment.
3
+ *
4
+ * The owner's terminal and the bot are different processes with different
5
+ * environments: a bot started by launchd has none of the shell's variables,
6
+ * so Claude Code can be signed in for the terminal and signed out for the
7
+ * bot. The HQ app's own preflight runs with the app's environment and cannot
8
+ * see this either. Only the bot can, so it asks the tool itself (`claude auth
9
+ * status --json`, `codex login status`) with the exact environment its turns
10
+ * use, at start and before each turn, and reports the answer in status.json.
11
+ * The app reads it through `hq bot status --json` / `hq bot list --json`
12
+ * before it sends a brief job.
13
+ */
14
+ import { defaultSpawn } from "./runtime/index.js";
15
+ import { SIGN_IN_CHECK_TIMEOUT_MS } from "./runtime-sign-in.js";
16
+ /**
17
+ * Ask the tool whether it is signed in. Never throws: a tool that cannot be
18
+ * asked (not installed, hung, no status command) answers `signedIn: null`,
19
+ * and the turn then runs as before and reports its own failure.
20
+ */
21
+ export async function checkRuntimeSignIn(opts) {
22
+ const checkedAt = (opts.now ?? (() => new Date()))().toISOString();
23
+ const status = opts.runtime.signInStatus;
24
+ if (!status)
25
+ return { signedIn: null, checkedAt, detail: `${opts.runtime.id} has no sign-in status command` };
26
+ const spawn = opts.spawn ?? defaultSpawn;
27
+ const env = { ...(opts.runtime.runtimeEnv?.(opts.env) ?? opts.env), HQ_NO_ENSURE_HQ_DESKTOP: "1" };
28
+ let binary;
29
+ try {
30
+ binary = opts.runtime.resolveBinary?.(env, opts.cwd, opts.platform ?? process.platform) ?? opts.runtime.binary;
31
+ }
32
+ catch {
33
+ binary = opts.runtime.binary;
34
+ }
35
+ try {
36
+ const result = await spawn(binary, status.args, { cwd: opts.cwd, env, timeoutMs: opts.timeoutMs ?? SIGN_IN_CHECK_TIMEOUT_MS });
37
+ const signedIn = status.parse(result);
38
+ if (signedIn === null) {
39
+ const tail = `${result.stderr ?? ""}`.trim().split("\n").filter(Boolean).slice(-2).join(" | ").slice(0, 200);
40
+ return {
41
+ signedIn: null,
42
+ checkedAt,
43
+ detail: `${opts.runtime.id} ${status.args.join(" ")} did not say (exit ${result.code ?? "?"}${tail ? `: ${tail}` : ""})`,
44
+ };
45
+ }
46
+ return { signedIn, checkedAt };
47
+ }
48
+ catch (err) {
49
+ const message = err instanceof Error ? err.message : String(err);
50
+ return { signedIn: null, checkedAt, detail: `${opts.runtime.id} ${status.args.join(" ")} could not run: ${message.slice(0, 200)}` };
51
+ }
52
+ }
53
+ //# sourceMappingURL=runtime-sign-in-check.js.map
@@ -13,12 +13,41 @@ import type { BotRuntimeId } from "./config.js";
13
13
  export declare const SIGN_IN_RETRY_MS = 60000;
14
14
  export declare function isRuntimeSignInFailure(text: string | undefined | null): boolean;
15
15
  export declare function runtimeDisplayName(runtime: BotRuntimeId | string): string;
16
- /** Reported in status.json and `hq bot list --json` while the sign-in is broken. */
16
+ /**
17
+ * Reported in status.json and `hq bot list --json` while the sign-in is broken.
18
+ *
19
+ * expired a turn failed because the tool's login no longer works.
20
+ * signed-out the bot's own check (`claude auth status` in the bot's
21
+ * environment, before a turn) found no login at all. A bot
22
+ * started by launchd has a bare environment, so it can be
23
+ * signed out while the owner's terminal is signed in.
24
+ */
17
25
  export interface RuntimeSignInIssue {
18
- state: "expired";
26
+ state: "expired" | "signed-out";
19
27
  runtime: BotRuntimeId;
20
28
  since: string;
21
29
  }
30
+ /**
31
+ * What the bot's own sign-in check last said, in the bot's environment.
32
+ * `signedIn` is null when the tool could not be asked (not installed, timed
33
+ * out, a runtime with no status command).
34
+ */
35
+ export interface RuntimeSignInCheck {
36
+ signedIn: boolean | null;
37
+ checkedAt: string;
38
+ /** Why the check could not tell, for the log and the JSON output. */
39
+ detail?: string;
40
+ }
41
+ /** How long a passed check stands in for another before a turn. */
42
+ export declare const SIGN_IN_CHECK_FRESH_MS = 30000;
43
+ /** How often the bot asks the tool again while it is signed out. */
44
+ export declare const SIGN_IN_CHECK_MS = 15000;
45
+ /** The tool's status command may not hang a turn. */
46
+ export declare const SIGN_IN_CHECK_TIMEOUT_MS = 15000;
47
+ /** The terminal command that signs the tool in on this computer. */
48
+ export declare function signInCommand(runtime: BotRuntimeId | string): string;
49
+ /** The excerpt a turn held by the pre-turn check records (classifies as signed-out). */
50
+ export declare function signedOutExcerpt(runtime: BotRuntimeId | string): string;
22
51
  /** The one message a conversation gets when the bot cannot work until its tool signs in again. */
23
- export declare function signInNeededText(runtime: BotRuntimeId | string, kickoff?: boolean): string;
52
+ export declare function signInNeededText(runtime: BotRuntimeId | string, kickoff?: boolean, state?: RuntimeSignInIssue["state"]): string;
24
53
  //# sourceMappingURL=runtime-sign-in.d.ts.map
@@ -22,10 +22,25 @@ export function isRuntimeSignInFailure(text) {
22
22
  export function runtimeDisplayName(runtime) {
23
23
  return runtime === "claude" ? "Claude Code" : runtime === "codex" ? "Codex" : runtime === "grok" ? "Grok" : runtime;
24
24
  }
25
+ /** How long a passed check stands in for another before a turn. */
26
+ export const SIGN_IN_CHECK_FRESH_MS = 30_000;
27
+ /** How often the bot asks the tool again while it is signed out. */
28
+ export const SIGN_IN_CHECK_MS = 15_000;
29
+ /** The tool's status command may not hang a turn. */
30
+ export const SIGN_IN_CHECK_TIMEOUT_MS = 15_000;
31
+ /** The terminal command that signs the tool in on this computer. */
32
+ export function signInCommand(runtime) {
33
+ return runtime === "claude" ? "claude auth login" : runtime === "codex" ? "codex login" : runtime === "grok" ? "grok login" : `${runtime} login`;
34
+ }
35
+ /** The excerpt a turn held by the pre-turn check records (classifies as signed-out). */
36
+ export function signedOutExcerpt(runtime) {
37
+ return `${runtimeDisplayName(runtime)} is not logged in on this computer (checked before the turn)`;
38
+ }
25
39
  /** The one message a conversation gets when the bot cannot work until its tool signs in again. */
26
- export function signInNeededText(runtime, kickoff = false) {
40
+ export function signInNeededText(runtime, kickoff = false, state = "expired") {
27
41
  const tool = runtimeDisplayName(runtime);
28
- return (`I can't work right now: ${tool} needs you to sign in again on this computer. ` +
42
+ const why = state === "signed-out" ? `${tool} isn't signed in on this computer` : `${tool} needs you to sign in again on this computer`;
43
+ return (`I can't work right now: ${why}. ` +
29
44
  `Use "Sign in to ${tool}" in HQ, and I'll ${kickoff ? "get started" : "answer this"} as soon as it works again. No need to send it twice.`);
30
45
  }
31
46
  //# sourceMappingURL=runtime-sign-in.js.map
@@ -2,7 +2,7 @@
2
2
  * status.json — atomic, metadata-only runtime status (local-bots US-003).
3
3
  */
4
4
  import type { BotRuntimeId } from "./config.js";
5
- import type { RuntimeSignInIssue } from "./runtime-sign-in.js";
5
+ import type { RuntimeSignInCheck, RuntimeSignInIssue } from "./runtime-sign-in.js";
6
6
  export type BotState = "starting" | "running" | "stopped" | "failed";
7
7
  export interface BotStatusFile {
8
8
  v: 1;
@@ -44,6 +44,12 @@ export interface BotStatusFile {
44
44
  * reason); cleared by the next turn that works. `null` = known to work.
45
45
  */
46
46
  runtimeSignIn?: RuntimeSignInIssue | null;
47
+ /**
48
+ * The bot's own last look at its coding tool's sign-in, in the bot's
49
+ * environment (at start, before a turn, and while signed out). What
50
+ * `hq bot status --json` reports as `runtimeSignedIn`.
51
+ */
52
+ runtimeSignInCheck?: RuntimeSignInCheck | null;
47
53
  /**
48
54
  * Set when the installed coding tool is too old for the model it was asked
49
55
  * to use and the last turn ran on `to` instead (`from` is "" when the model
@@ -65,6 +71,8 @@ export interface BotStatusFile {
65
71
  durationMs?: number;
66
72
  stderr: string;
67
73
  };
74
+ /** When the last model turn that worked had STARTED (ISO); a later crash is not cleared by it. */
75
+ lastTurnOkAt?: string;
68
76
  /**
69
77
  * Set while the bot holds its model turns after FAILURE_LIMIT failures in a
70
78
  * window (it used to exit instead, and a bot that exited could neither load
@@ -83,6 +91,22 @@ export declare function defaultBotStatus(pid: number, runtime: BotRuntimeId, now
83
91
  export declare function readBotStatus(dir: string): BotStatusFile | null;
84
92
  export declare function writeBotStatus(dir: string, status: BotStatusFile): void;
85
93
  export declare function patchBotStatus(dir: string, patch: Partial<BotStatusFile>, fallback: () => BotStatusFile, now?: () => Date): BotStatusFile;
94
+ /** A coding-tool crash the bot has not recovered from yet (see `runtimeCrashSinceStart`). */
95
+ export interface RuntimeCrash {
96
+ at: string;
97
+ code: number | null;
98
+ durationMs?: number;
99
+ /** First line of the tool's stderr, at most 200 characters. */
100
+ message: string;
101
+ }
102
+ /**
103
+ * The coding tool crashed before its turn started, in this bot process, and
104
+ * no turn has worked since. Null when there is no such crash: none recorded,
105
+ * one from before the bot last started (a restart is the fix people reach
106
+ * for, so it clears this), or one followed by a good turn. Surfaces read it
107
+ * to warn before sending the bot work it would fail.
108
+ */
109
+ export declare function runtimeCrashSinceStart(status: BotStatusFile | null | undefined): RuntimeCrash | null;
86
110
  /** Live process check for a status file's pid. */
87
111
  export declare function isPidAlive(pid: number): boolean;
88
112
  //# sourceMappingURL=status.d.ts.map
@@ -51,6 +51,34 @@ export function patchBotStatus(dir, patch, fallback, now = () => new Date()) {
51
51
  writeBotStatus(dir, next);
52
52
  return next;
53
53
  }
54
+ /**
55
+ * The coding tool crashed before its turn started, in this bot process, and
56
+ * no turn has worked since. Null when there is no such crash: none recorded,
57
+ * one from before the bot last started (a restart is the fix people reach
58
+ * for, so it clears this), or one followed by a good turn. Surfaces read it
59
+ * to warn before sending the bot work it would fail.
60
+ */
61
+ export function runtimeCrashSinceStart(status) {
62
+ const crash = status?.lastCrash;
63
+ if (!crash)
64
+ return null;
65
+ const at = Date.parse(crash.at);
66
+ if (!Number.isFinite(at))
67
+ return null;
68
+ const started = Date.parse(status.startedAt);
69
+ if (Number.isFinite(started) && at < started)
70
+ return null;
71
+ const ok = status.lastTurnOkAt ? Date.parse(status.lastTurnOkAt) : Number.NaN;
72
+ if (Number.isFinite(ok) && ok >= at)
73
+ return null;
74
+ const message = (crash.stderr ?? "").split("\n").map((l) => l.trim()).find(Boolean)?.slice(0, 200) ?? "";
75
+ return {
76
+ at: crash.at,
77
+ code: crash.code,
78
+ ...(crash.durationMs !== undefined ? { durationMs: crash.durationMs } : {}),
79
+ message,
80
+ };
81
+ }
54
82
  /** Live process check for a status file's pid. */
55
83
  export function isPidAlive(pid) {
56
84
  if (!Number.isInteger(pid) || pid <= 0)
@@ -92,6 +92,8 @@ export interface SyncCoreRunning {
92
92
  }
93
93
  export declare const SYNC_CORE_RECONCILE_UNIT = "sync-core";
94
94
  export declare const SYNC_CORE_RECONCILE_INTERVAL_SEC = 600;
95
+ export declare const SYNC_CORE_MANIFEST_UNIT = "sync-core-manifest";
96
+ export declare const SYNC_CORE_MANIFEST_INTERVAL_SEC: number;
95
97
  export declare const SYNC_CORE_LEGACY_SETTLE_MS: number;
96
98
  export interface PlanContext {
97
99
  machineType: MachineType;
@@ -126,6 +128,8 @@ export interface PlanContext {
126
128
  now?: number;
127
129
  /** A legacy desktop app or sync unit is still managing sync. */
128
130
  desktopAppManagesSync: boolean;
131
+ /** A legacy sync unit (not the desktop app) that the daemon cannot migrate is installed. */
132
+ legacySyncUnitManagesSync?: boolean;
129
133
  /** Full process list, or null when the daemon could not read it. */
130
134
  syncRunnerProcesses: readonly SyncRunnerProcess[] | null;
131
135
  /** PID recorded in the watch-owner status, when it is readable. */
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import * as path from "node:path";
13
13
  import { syncCoreUnitId } from "../sync-core/engine-home.js";
14
+ import { engineOwnedManifestScopes } from "../sync-core/manifest-scopes.js";
14
15
  import { SYNC_RUNNER_OWNER, syncRunnerUnleasedBlockReason } from "./sync-runner.js";
15
16
  import { vaultSkipEnv } from "../vault-sync-switches.js";
16
17
  export const SERVICE_NAMES = [
@@ -82,10 +83,14 @@ export const DEFAULT_SERVICES = {
82
83
  /** Defaults plus this host's overrides, in catalog order. Unknown names are ignored. */
83
84
  export function enabledServices(machineType, overrides) {
84
85
  const defaults = DEFAULT_SERVICES[machineType];
85
- return SERVICE_NAMES.filter((s) => (defaults.includes(s) || overrides.enable.includes(s)) && !overrides.disable.includes(s));
86
+ const wanted = (s) => (defaults.includes(s) || overrides.enable.includes(s)) && !overrides.disable.includes(s);
87
+ const wakeSubscriber = wanted("sync-core") && !overrides.disable.includes("mesh");
88
+ return SERVICE_NAMES.filter((s) => wanted(s) || (s === "mesh" && wakeSubscriber));
86
89
  }
87
90
  export const SYNC_CORE_RECONCILE_UNIT = "sync-core";
88
91
  export const SYNC_CORE_RECONCILE_INTERVAL_SEC = 600;
92
+ export const SYNC_CORE_MANIFEST_UNIT = "sync-core-manifest";
93
+ export const SYNC_CORE_MANIFEST_INTERVAL_SEC = 24 * 3600;
89
94
  export const SYNC_CORE_LEGACY_SETTLE_MS = 10 * 60 * 1000;
90
95
  function base(service, over = {}) {
91
96
  return {
@@ -127,6 +132,20 @@ export const CLAUDE_RUNNER_SCRIPT = "/usr/local/bin/outpost-runner.sh";
127
132
  const NO_HQ_ROOT = "no HQ folder configured (run `hq daemon install --hq-root <path>`)";
128
133
  const NOT_SIGNED_IN = "not signed in (run `hq login`)";
129
134
  const KIT_NOT_SET_UP = "the agent kit is not set up on this machine (run `hq agent kit install`)";
135
+ function syncOwnerBlock(ctx) {
136
+ if (ctx.machineType === "desktop" && ctx.syncOwner === "desktop-app" && ctx.desktopAppManagesSync) {
137
+ return "the desktop app is still managing sync (run `hq daemon sync take-ownership` after it stops its runner)";
138
+ }
139
+ if (ctx.machineType === "desktop" && ctx.syncOwner === "daemon" && !ctx.syncPaused && ctx.hqRoot && ctx.signedIn) {
140
+ return ctx.syncRunnerProcesses === null
141
+ ? "could not check for an existing sync runner; waiting to avoid starting a duplicate"
142
+ : syncRunnerUnleasedBlockReason(ctx.hqRoot, ctx.syncRunnerProcesses, ctx.syncRunnerOwnerPid, ctx.platform, ctx.daemonSyncRunnerPids);
143
+ }
144
+ return undefined;
145
+ }
146
+ function daemonAloneRunsLegacySync(ctx) {
147
+ return ctx.syncOwner === "daemon" && ctx.legacySyncUnitManagesSync !== true && syncOwnerBlock(ctx) === undefined;
148
+ }
130
149
  function planService(service, ctx) {
131
150
  const agentMachine = ctx.machineType === "agent-kit" || ctx.machineType === "agent-box";
132
151
  switch (service) {
@@ -139,19 +158,7 @@ function planService(service, ctx) {
139
158
  offCompanies: new Set([...(ctx.vaultSync?.offCompanies ?? []), ...running.companies]),
140
159
  }
141
160
  : ctx.vaultSync;
142
- let ownerBlock;
143
- if (ctx.machineType === "desktop" && ctx.syncOwner === "desktop-app" && ctx.desktopAppManagesSync) {
144
- ownerBlock = "the desktop app is still managing sync (run `hq daemon sync take-ownership` after it stops its runner)";
145
- }
146
- else if (ctx.machineType === "desktop" &&
147
- ctx.syncOwner === "daemon" &&
148
- !ctx.syncPaused &&
149
- ctx.hqRoot &&
150
- ctx.signedIn) {
151
- ownerBlock = ctx.syncRunnerProcesses === null
152
- ? "could not check for an existing sync runner; waiting to avoid starting a duplicate"
153
- : syncRunnerUnleasedBlockReason(ctx.hqRoot, ctx.syncRunnerProcesses, ctx.syncRunnerOwnerPid, ctx.platform, ctx.daemonSyncRunnerPids);
154
- }
161
+ const ownerBlock = syncOwnerBlock(ctx);
155
162
  const commonBlock = firstBlock([
156
163
  [ctx.syncPaused, "sync is paused (run `hq daemon sync resume`)"],
157
164
  [
@@ -219,7 +226,8 @@ function planService(service, ctx) {
219
226
  : ctx.vaultSync?.offCompanies.has(vault.slug ?? "") === true;
220
227
  const offSince = vault.legacyOffSince ? Date.parse(vault.legacyOffSince) : Number.NaN;
221
228
  const settled = Number.isFinite(offSince) && now - offSince >= SYNC_CORE_LEGACY_SETTLE_MS;
222
- const legacyReady = ctx.legacySyncReadyForCore === true && (!ctx.desktopAppManagesSync || settled);
229
+ const settleNeeded = ctx.desktopAppManagesSync && !daemonAloneRunsLegacySync(ctx);
230
+ const legacyReady = ctx.legacySyncReadyForCore === true && (!settleNeeded || settled);
223
231
  const what = vault.kind === "personal" ? "the personal vault" : `the ${vault.slug} vault`;
224
232
  return base(service, {
225
233
  id: syncCoreUnitId(vault.uid),
@@ -232,11 +240,10 @@ function planService(service, ctx) {
232
240
  HQ_STATE_DIR: path.join(ctx.home, ".hq"),
233
241
  HQ_SYNC_ENGINE: "rust-test",
234
242
  HQ_SYNC_MODE: full ? "full" : "readonly",
235
- ...(full ? { HQ_SYNC_FULL_VAULT: "1" } : {}),
236
- HQ_SYNC_PROTOCOL: "1",
243
+ ...(full ? { HQ_SYNC_FULL_VAULT: "1", HQ_SYNC_PROTOCOL: "1" } : {}),
237
244
  HQ_SYNC_CORE_RELEASE: vault.release,
238
245
  },
239
- heartbeatTimeoutMs: 180_000,
246
+ ...(full ? { heartbeatTimeoutMs: 180_000 } : {}),
240
247
  blocked: firstBlock([
241
248
  [ctx.syncPaused, "sync is paused (run `hq daemon sync resume`)"],
242
249
  [ctx.vaultSync?.allOff === true, "cloud sync is off for every vault on this computer (turn it back on in HQ Settings > Sync)"],
@@ -250,7 +257,28 @@ function planService(service, ctx) {
250
257
  ]),
251
258
  });
252
259
  });
253
- return [reconcile, ...engines];
260
+ const manifestScopes = engineOwnedManifestScopes(ctx.vaultSync, ctx.syncCoreVaults);
261
+ if (manifestScopes.length === 0)
262
+ return [reconcile, ...engines];
263
+ const manifest = base(service, {
264
+ id: SYNC_CORE_MANIFEST_UNIT,
265
+ description: `uploads a daily sync-audit manifest for ${manifestScopes.map((scope) => scope.kind === "personal" ? "the personal vault" : `the ${scope.slug} vault`).join(", ")}`,
266
+ kind: "scheduled",
267
+ intervalSec: SYNC_CORE_MANIFEST_INTERVAL_SEC,
268
+ initialDelaySec: 300,
269
+ command: {
270
+ type: "hq",
271
+ args: ["daemon", "service", "sync-core-manifest", ...(ctx.hqRoot ? ["--hq-root", ctx.hqRoot] : [])],
272
+ },
273
+ env: { HQ_NO_UPDATE_CHECK: "1" },
274
+ backgroundQos: true,
275
+ blocked: firstBlock([
276
+ [ctx.syncPaused, "sync is paused (run `hq daemon sync resume`)"],
277
+ [!ctx.hqRoot, NO_HQ_ROOT],
278
+ [!ctx.signedIn, NOT_SIGNED_IN],
279
+ ]),
280
+ });
281
+ return [reconcile, ...engines, manifest];
254
282
  }
255
283
  case "mesh": {
256
284
  const args = ["mesh", "daemon", "run", ...(ctx.hqRoot ? ["--hq-root", ctx.hqRoot] : [])];
@@ -1021,6 +1021,7 @@ export async function runDaemon(opts) {
1021
1021
  syncOwner,
1022
1022
  syncCoreVaults: syncCoreVaultPlans(home, hqRoot, platform),
1023
1023
  desktopAppManagesSync: appManagesSync,
1024
+ legacySyncUnitManagesSync: legacy.some((u) => u.service === "sync" && !u.migratable),
1024
1025
  syncRunnerProcesses,
1025
1026
  syncRunnerOwnerPid,
1026
1027
  daemonSyncRunnerPids,