@indigoai-us/hq-cli 5.345.50 → 5.345.51

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,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.51] — 2026-10-08
6
+
7
+ - Multi-company session locking now defaults on across native CLI readers; an explicit false flag value remains the kill switch.
8
+
9
+ - The Codex skill bridge regression suite now checks that the repo `.agents/skills` mirror exposes every skill from the Claude listing source once.
10
+ - `hq lanes message`, `resume` and `grant` now reach an Outpost-hosted lane that has no local record even when the owner's lane store is too large for one hierarchy read. The lane, its senior, its siblings and its children are read in bounded parts instead of the command failing with `lane_not_found`.
11
+ - HQ CLI compares LaunchAgent plists before writing or reloading them, attributes jobs to an installed HQ.app, uses its signed launcher when available, and migrates legacy Node-first agents once.
12
+
5
13
  ## [5.345.50] — 2026-10-08
6
14
  - The lane keep-alive audit now admits every `done` stop envelope and splits the unrestarted ones by keep-alive eligibility: a stop keep-alive is specified not to wake is reported as `finished_story` or `idle_no_queued_work` instead of being counted as a liveness failure. `stopped_and_not_restarted_on_their_own` keeps its name and its value on the stops the audit already reported.
7
15
  - A finished standing lane that receives queued work long after it stopped now gets the normal grace period measured from when that work appeared, so a normal exit from `idle_no_queued_work` is no longer reported as a keep-alive liveness failure.
@@ -781,11 +781,11 @@ export async function runHqSessionFast(argv, _stdin, cwd, hqRoot) {
781
781
  || (commandArgs[0] === "get" && commandArgs[1] === "company_slugs");
782
782
  let enabled = false;
783
783
  if (needsMultiCompanyFlag) {
784
- const [{ fastFlagEnabled }, { MULTI_COMPANY_SESSION_LOCK_FLAG }] = await Promise.all([
784
+ const [{ fastFlagEnabledDefaultOn }, { MULTI_COMPANY_SESSION_LOCK_FLAG }] = await Promise.all([
785
785
  import("../native-fast-route.js"),
786
786
  import("../lib/cli-flag-keys.js"),
787
787
  ]);
788
- enabled = await fastFlagEnabled(MULTI_COMPANY_SESSION_LOCK_FLAG);
788
+ enabled = await fastFlagEnabledDefaultOn(MULTI_COMPANY_SESSION_LOCK_FLAG);
789
789
  }
790
790
  const result = executeHqSession(argv, { cwd, hqRoot }, { multiCompanyLockEnabled: enabled });
791
791
  if (result.stdout)
@@ -4,7 +4,7 @@ import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { awkCannotOpenFileDiagnostic } from "../lib/core-utils/awk-diagnostics.js";
6
6
  import { buildCmdExeSpawnPlan, isWindowsBatchFile } from "../utils/windows-spawn.js";
7
- import { fastFlagEnabled } from "../native-fast-route.js";
7
+ import { fastFlagEnabledDefaultOn } from "../native-fast-route.js";
8
8
  export const RESOLVE_COMPANY_ASSET = "core/scripts/resolve-company.sh";
9
9
  export const RESOLVE_COMPANY_HELP = [
10
10
  "hq-core: public",
@@ -603,7 +603,7 @@ export async function runResolveCompanyFast(args, stdin, cwd, hqRoot, _commandNa
603
603
  hqRoot,
604
604
  stdin: actualStdin,
605
605
  stdinIsTty: process.stdin.isTTY === true,
606
- multiCompanyLockEnabled: await fastFlagEnabled("hooks.multi-company-session-lock"),
606
+ multiCompanyLockEnabled: await fastFlagEnabledDefaultOn("hooks.multi-company-session-lock"),
607
607
  });
608
608
  process.stdout.write(result.stdout);
609
609
  if (result.stderr.length)
@@ -947,6 +947,7 @@ function laneListQueryFlags(query) {
947
947
  ...(query.laneUlids?.length ? ["--lane-ulid"] : []),
948
948
  ...(query.active ? ["--active"] : []),
949
949
  ...(query.hierarchy?.length ? ["--hierarchy"] : []),
950
+ ...(query.senior !== undefined ? ["--senior"] : []),
950
951
  ...(query.snapshot ? ["--snapshot"] : []),
951
952
  ];
952
953
  }
@@ -1404,6 +1405,7 @@ function normalizedLaneListQuery(query) {
1404
1405
  ...(laneUlids === undefined ? {} : { laneUlids }),
1405
1406
  ...(query.active ? { active: true } : {}),
1406
1407
  ...(hierarchy === undefined ? {} : { hierarchy }),
1408
+ ...(query.senior === undefined ? {} : { senior: query.senior }),
1407
1409
  ...(query.snapshot ? { snapshot: true } : {}),
1408
1410
  ...(query.strictSnapshot ? { strictSnapshot: true } : {}),
1409
1411
  ...(query.includeForeignHostRows ? { includeForeignHostRows: true } : {}),
@@ -1503,6 +1505,48 @@ async function readOutpostLaneRecords(ctx, outpostId, requestedQuery = {}, dispa
1503
1505
  }
1504
1506
  return invocationMemo.getOrCreate(outpostId, query, () => readOutpostLaneRecordsUncached(ctx, outpostId, query, dispatchOptions));
1505
1507
  }
1508
+ /**
1509
+ * Read the owner rows the hire-grant context for `laneId` is built from.
1510
+ *
1511
+ * The owner's `--hierarchy` selector also returns every lane that shares the
1512
+ * target's company and project, so on a store with hundreds of lanes in one
1513
+ * project the response reaches the Outpost exec output limit and the read
1514
+ * fails before a single row is parsed. When that happens, and the target has a
1515
+ * lane senior, assemble the rows the context builder needs from bounded reads
1516
+ * every owner CLI supports: the target and its senior by id, the senior's
1517
+ * direct children (the target's siblings) and the target's own children by
1518
+ * `--senior`. The owner rejects `--senior` together with `--snapshot`, so the
1519
+ * two `--senior` reads use the plain JSON list; the dispatched command sets
1520
+ * HQ_LANES_REMOTE_READ=1, which keeps that list from refreshing remote
1521
+ * mirrors, and the view-only fields it adds are stripped before parsing. A
1522
+ * session-senior target needs same-company profile rows that no bounded
1523
+ * filter selects, so its oversized read stays an error.
1524
+ */
1525
+ async function readOutpostLaneHierarchy(ctx, outpostId, laneId) {
1526
+ let tooLarge;
1527
+ try {
1528
+ return await readOutpostLaneRecords(ctx, outpostId, { hierarchy: [laneId], snapshot: true });
1529
+ }
1530
+ catch (err) {
1531
+ if (!isLanesError(err) || err.error !== "outpost_lane_list_too_large")
1532
+ throw err;
1533
+ tooLarge = err;
1534
+ }
1535
+ const targets = await readOutpostLaneRecords(ctx, outpostId, { laneIds: [laneId], snapshot: true });
1536
+ const target = targets.find((lane) => lane.lane_id === laneId);
1537
+ if (target === undefined)
1538
+ return targets;
1539
+ if (target.senior.kind !== "lane" || !target.senior.id) {
1540
+ throw new LanesError(tooLarge.error, `${tooLarge.message}; lane ${laneId} has a ${target.senior.kind} senior, whose hierarchy cannot be read in bounded parts`, { ...tooLarge.details, lane_id: laneId, senior: target.senior });
1541
+ }
1542
+ const seniorId = target.senior.id;
1543
+ const [seniorRows, siblings, children] = await Promise.all([
1544
+ readOutpostLaneRecords(ctx, outpostId, { laneIds: [seniorId], snapshot: true }),
1545
+ readOutpostLaneRecords(ctx, outpostId, { senior: `lane:${seniorId}` }),
1546
+ readOutpostLaneRecords(ctx, outpostId, { senior: `lane:${laneId}` }),
1547
+ ]);
1548
+ return mergeOwnerLaneRecords([...targets, ...seniorRows, ...siblings], children);
1549
+ }
1506
1550
  /** Refresh this host's selected Outpost mirrors in one bounded owner query per Outpost. */
1507
1551
  async function refreshRemoteLaneMirrors(ctx, hqRoot, records, parentLaneIds, options = {}) {
1508
1552
  const result = {
@@ -1829,9 +1873,12 @@ async function resolveCommandLaneTarget(ctx, laneId, options = {}) {
1829
1873
  for (const outpostId of lookupOutpostIds) {
1830
1874
  let ownerLanes;
1831
1875
  try {
1832
- ownerLanes = await readOutpostLaneRecords(ctx, outpostId, hostlessUlidLookup
1833
- ? { laneUlids: [requestedUlid], snapshot: true }
1834
- : { hierarchy: [laneId], snapshot: true });
1876
+ // The hierarchy read is shared with the grant context built after
1877
+ // resolution, so one owner round trip serves both whenever the Outpost
1878
+ // exec output limit can carry the owner's response.
1879
+ ownerLanes = hostlessUlidLookup
1880
+ ? await readOutpostLaneRecords(ctx, outpostId, { laneUlids: [requestedUlid], snapshot: true })
1881
+ : await readOutpostLaneHierarchy(ctx, outpostId, laneId);
1835
1882
  probedOutpostIds.push(outpostId);
1836
1883
  }
1837
1884
  catch (err) {
@@ -4665,20 +4712,14 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options, behavior =
4665
4712
  let grantContext;
4666
4713
  let grantHierarchy;
4667
4714
  if (action === "grant") {
4668
- const ownerHierarchy = mergeOwnerLaneRecords(loadAllLanes(ctx.hqRoot()), await readOutpostLaneRecords(ctx, outpostId, {
4669
- hierarchy: [lane.lane_id],
4670
- snapshot: true,
4671
- }));
4715
+ const ownerHierarchy = mergeOwnerLaneRecords(loadAllLanes(ctx.hqRoot()), await readOutpostLaneHierarchy(ctx, outpostId, lane.lane_id));
4672
4716
  const hierarchy = await hierarchyWithVerifiedRemoteSenior(ctx, ctx.hqRoot(), lane, ownerHierarchy);
4673
4717
  grantHierarchy = hierarchy;
4674
4718
  grantContext = createOutpostHireGrantContext(lane, hierarchy);
4675
4719
  }
4676
4720
  else if ((action === "resume" && lane.senior.kind === "lane") ||
4677
4721
  (action === "message" && (lane.senior.kind === "lane" || lane.senior.kind === "session"))) {
4678
- const ownerHierarchy = mergeOwnerLaneRecords(loadAllLanes(ctx.hqRoot()), await readOutpostLaneRecords(ctx, outpostId, {
4679
- hierarchy: [lane.lane_id],
4680
- snapshot: true,
4681
- }));
4722
+ const ownerHierarchy = mergeOwnerLaneRecords(loadAllLanes(ctx.hqRoot()), await readOutpostLaneHierarchy(ctx, outpostId, lane.lane_id));
4682
4723
  const hierarchy = await hierarchyWithVerifiedRemoteSenior(ctx, ctx.hqRoot(), lane, ownerHierarchy);
4683
4724
  grantContext = createOutpostHireGrantContext(lane, hierarchy, action === "message" ? options.supersededMessageCallerId : undefined, action === "message" ? options.supersededMessageSessionId : undefined);
4684
4725
  }
@@ -145,7 +145,7 @@ import { ensureCognitoToken } from "../utils/cognito-session.js";
145
145
  import { removeCacheEntry, clearAllCache } from "../utils/secrets-cache.js";
146
146
  import { isHqCliChild, releaseChildWrapperPackageUseLeaseAfterStart } from "../utils/child-wrapper-lease.js";
147
147
  import { resolveDefaultHqRoot } from "../utils/hq-root-resolver.js";
148
- import { fastFlagEnabled } from "../native-fast-route.js";
148
+ import { fastFlagEnabledDefaultOn } from "../native-fast-route.js";
149
149
  import { MULTI_COMPANY_SESSION_LOCK_FLAG } from "../lib/cli-flag-keys.js";
150
150
  import { resolveSessionId } from "../lib/core-utils/session-id.js";
151
151
  import { secretsExecChildExitCode } from "../utils/child-wrapper-exit.js";
@@ -1249,7 +1249,7 @@ export function registerSecretsCommand(program) {
1249
1249
  process.exit(1);
1250
1250
  }
1251
1251
  const keys = parseSecretNameList(_opts.only);
1252
- const multiCompanyLockEnabled = await fastFlagEnabled(MULTI_COMPANY_SESSION_LOCK_FLAG);
1252
+ const multiCompanyLockEnabled = await fastFlagEnabledDefaultOn(MULTI_COMPANY_SESSION_LOCK_FLAG);
1253
1253
  const sessionLock = multiCompanyLockEnabled ? readSessionCompanyLock(process.env) : undefined;
1254
1254
  const parentScope = secrets.opts();
1255
1255
  const inheritedCompany = parentScope.company;
@@ -7,7 +7,7 @@ import { ensureCognitoToken } from "../utils/cognito-session.js";
7
7
  import { vaultApiFetch, getCompanyUid } from "./secrets.js";
8
8
  import { GROUP_ID_PATTERN, EMAIL_PATTERN, toSharedFolderGlob } from "./_patterns.js";
9
9
  import { findHqRoot } from "../utils/manifest.js";
10
- import { fastFlagEnabled } from "../native-fast-route.js";
10
+ import { fastFlagEnabledDefaultOn } from "../native-fast-route.js";
11
11
  import { MULTI_COMPANY_SESSION_LOCK_FLAG } from "../lib/cli-flag-keys.js";
12
12
  import { resolveSessionId } from "../lib/core-utils/session-id.js";
13
13
  export { readWorkerRegistry } from "../lib/workers-registry/read.js";
@@ -198,7 +198,7 @@ async function runWorkersShare(workerId, opts) {
198
198
  }
199
199
  async function runWorkersList(opts) {
200
200
  const hqRoot = findHqRoot();
201
- const enabled = await fastFlagEnabled(MULTI_COMPANY_SESSION_LOCK_FLAG);
201
+ const enabled = await fastFlagEnabledDefaultOn(MULTI_COMPANY_SESSION_LOCK_FLAG);
202
202
  const lockedCompanies = resolveActiveCompanies(hqRoot, enabled);
203
203
  if (enabled && opts.company && denyWorkerCompanyOutsideLock(opts.company, lockedCompanies))
204
204
  return;
@@ -64,7 +64,7 @@ export interface BotDaemonDeps {
64
64
  platform?: BotDaemonPlatform;
65
65
  launchctl?: LaunchctlExec;
66
66
  uid?: number;
67
- fsImpl?: Pick<typeof fs, "existsSync" | "writeFileSync" | "mkdirSync" | "unlinkSync">;
67
+ fsImpl?: Pick<typeof fs, "existsSync" | "readFileSync" | "writeFileSync" | "mkdirSync" | "unlinkSync"> & Partial<Pick<typeof fs, "chmodSync" | "renameSync">>;
68
68
  /** Blocking sleep, injectable for tests. */
69
69
  sleepMs?: (ms: number) => void;
70
70
  /**
@@ -15,7 +15,7 @@ import * as path from "node:path";
15
15
  import { spawn } from "node:child_process";
16
16
  import { resolveHqBinary, resolveNodeBinary, preferStableHqBinary } from "../mesh/live/daemon/install.js";
17
17
  import { botLogPath, launchdLabel } from "./paths.js";
18
- import { reloadLaunchd } from "../service-manager/launchd.js";
18
+ import { discoverLaunchdIdentity, installLaunchdPlist, launchdProgramArguments, launchdPlistProgramArguments as parsePlistArguments, parseLaunchdCommand, reloadLaunchd } from "../service-manager/launchd.js";
19
19
  import { nodeBinaryProblem, preferStableNodeBinary } from "../node-binary.js";
20
20
  export function detectBotPlatform(platform = process.platform) {
21
21
  if (platform === "darwin")
@@ -66,6 +66,12 @@ function escapeXml(value) {
66
66
  .replace(/"/g, "&quot;");
67
67
  }
68
68
  export function renderBotLaunchdPlist(p) {
69
+ const identity = discoverLaunchdIdentity(p.home);
70
+ const args = launchdProgramArguments({
71
+ nodeBinary: p.nodeBinary,
72
+ hqBinary: p.hqBinary,
73
+ launchdLauncherBinary: identity.launcherBinary,
74
+ }, ["bot", "run", p.name]);
69
75
  const envRows = [
70
76
  ["HOME", p.home],
71
77
  ["PATH", p.pathEntries.join(":")],
@@ -80,13 +86,9 @@ export function renderBotLaunchdPlist(p) {
80
86
  <dict>
81
87
  <key>Label</key>
82
88
  <string>${escapeXml(p.label)}</string>
83
- <key>ProgramArguments</key>
89
+ ${identity.associatedBundleIdentifier ? ` <key>AssociatedBundleIdentifiers</key>\n <array>\n <string>${escapeXml(identity.associatedBundleIdentifier)}</string>\n </array>\n` : ""} <key>ProgramArguments</key>
84
90
  <array>
85
- <string>${escapeXml(p.nodeBinary)}</string>
86
- <string>${escapeXml(p.hqBinary)}</string>
87
- <string>bot</string>
88
- <string>run</string>
89
- <string>${escapeXml(p.name)}</string>
91
+ ${args.map((arg) => ` <string>${escapeXml(arg)}</string>`).join("\n")}
90
92
  </array>
91
93
  <key>WorkingDirectory</key>
92
94
  <string>${escapeXml(p.hqRoot ?? p.botDir)}</string>
@@ -190,16 +192,13 @@ function launchdLoaded(p, deps) {
190
192
  return deps.launchctl(["print", `${launchdDomain(deps.uid)}/${p.label}`]).ok;
191
193
  }
192
194
  export function launchdPlistProgramArguments(plist) {
193
- const block = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
194
- if (!block)
195
- return [];
196
- return [...block[1].matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1]);
195
+ return parsePlistArguments(plist);
197
196
  }
198
197
  /** The hq script path baked into an installed unit, or null if none. */
199
198
  export function installedDaemonHqBinary(p, io = fs) {
200
199
  if (io.existsSync(p.plistPath)) {
201
200
  const args = launchdPlistProgramArguments(io.readFileSync(p.plistPath, "utf8"));
202
- return args[1] ?? null;
201
+ return parseLaunchdCommand(args)?.hq ?? null;
203
202
  }
204
203
  if (io.existsSync(p.systemdUnitPath)) {
205
204
  const unit = io.readFileSync(p.systemdUnitPath, "utf8");
@@ -212,7 +211,7 @@ export function installedDaemonHqBinary(p, io = fs) {
212
211
  export function installedDaemonNodeBinary(p, io = fs) {
213
212
  if (io.existsSync(p.plistPath)) {
214
213
  const args = launchdPlistProgramArguments(io.readFileSync(p.plistPath, "utf8"));
215
- return args[0] ?? null;
214
+ return parseLaunchdCommand(args)?.node ?? null;
216
215
  }
217
216
  if (io.existsSync(p.systemdUnitPath)) {
218
217
  const unit = io.readFileSync(p.systemdUnitPath, "utf8");
@@ -383,19 +382,27 @@ export function installBotDaemon(p, deps = {}) {
383
382
  io.mkdirSync(path.dirname(p.logPath), { recursive: true, mode: 0o700 });
384
383
  if (platform === "darwin") {
385
384
  io.mkdirSync(path.dirname(p.plistPath), { recursive: true });
386
- // Replace rather than overwrite, so re-adopting a bot over the stale plist
387
- // a wiped ~/.hq left behind lands a fresh file at 0600 (writeFileSync
388
- // keeps the mode an existing file already has).
389
- if (io.existsSync(p.plistPath))
390
- io.unlinkSync(p.plistPath);
391
- io.writeFileSync(p.plistPath, renderBotLaunchdPlist(p), { mode: 0o600 });
392
- let loaded = false;
393
- if (deps.launchctl) {
394
- // bootout first so a re-install picks up a changed plist.
395
- const r = rebootstrapLaunchd(p, deps);
396
- if (!r.ok) {
397
- const detail = `launchctl bootstrap failed after ${r.bootstrapAttempts} attempts: ${(r.error ?? "").slice(0, 200)}`;
398
- if (r.loaded) {
385
+ const reloadState = { result: null };
386
+ const written = installLaunchdPlist(p.plistPath, p.label, renderBotLaunchdPlist(p), {
387
+ files: {
388
+ existsSync: io.existsSync,
389
+ readFileSync: (file) => io.readFileSync(file, "utf8"),
390
+ writeFileSync: io.writeFileSync,
391
+ ...(io.chmodSync ? { chmodSync: io.chmodSync } : {}),
392
+ ...(io.renameSync ? { renameSync: io.renameSync } : {}),
393
+ unlinkSync: io.unlinkSync,
394
+ },
395
+ activate: Boolean(deps.launchctl),
396
+ isLoaded: () => launchdLoaded(p, deps),
397
+ ...(deps.launchctl ? { reload: () => { reloadState.result = rebootstrapLaunchd(p, deps); return reloadState.result; } } : {}),
398
+ });
399
+ let loaded = written.loaded;
400
+ if (deps.launchctl && reloadState.result) {
401
+ const r = reloadState.result;
402
+ loaded = r.loaded;
403
+ if (!r?.ok) {
404
+ const detail = `launchctl bootstrap failed after ${r?.bootstrapAttempts ?? 0} attempts: ${(r?.error ?? "").slice(0, 200)}`;
405
+ if (r?.loaded) {
399
406
  // The previous definition survived the bootout; keep it running.
400
407
  deps.launchctl(["kickstart", "-k", `${launchdDomain(deps.uid)}/${p.label}`]);
401
408
  }
@@ -403,14 +410,13 @@ export function installBotDaemon(p, deps = {}) {
403
410
  platform,
404
411
  action: "install",
405
412
  installed: true,
406
- loaded: r.loaded,
413
+ loaded: r?.loaded ?? false,
407
414
  dest: p.plistPath,
408
- message: r.loaded
415
+ message: r?.loaded
409
416
  ? `Wrote ${p.plistPath} but ${detail}. The previous definition of ${p.label} is still loaded and was restarted; the rewritten plist is not active yet.`
410
417
  : `Wrote ${p.plistPath} but ${detail}. ${p.label} is NOT loaded. Load it with: launchctl bootstrap ${launchdDomain(deps.uid)} ${p.plistPath}`,
411
418
  };
412
419
  }
413
- loaded = true;
414
420
  }
415
421
  return {
416
422
  platform,
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
- import { activateLaunchd, deactivateLaunchd, launchdPlistPath, renderLaunchdPlist, } from "../service-manager/launchd.js";
20
+ import { activateLaunchd, deactivateLaunchd, defaultRunCommand, installLaunchdPlist, launchdIsLoaded, launchdPlistPath, renderLaunchdPlist, } from "../service-manager/launchd.js";
21
21
  import { activateSystemd, deactivateSystemd, renderSystemdUserUnit, systemdUnitPath, } from "../service-manager/systemd.js";
22
22
  import { escapeXml, shellQuote } from "../service-manager/types.js";
23
23
  import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
@@ -251,12 +251,43 @@ export function installDaemonUnit(host, options, deps) {
251
251
  fs.mkdirSync(path.dirname(plan.unitPath), { recursive: true });
252
252
  if (plan.kind !== "systemd-system")
253
253
  fs.mkdirSync(path.join(host.daemonDir, "logs"), { recursive: true });
254
- if (plan.kind === "schtasks")
254
+ if (plan.kind === "launchd") {
255
+ let plist;
256
+ try {
257
+ plist = installLaunchdPlist(plan.unitPath, DAEMON_LAUNCHD_LABEL, plan.rendered, {
258
+ run: deps.run ?? defaultRunCommand,
259
+ uid: host.uid,
260
+ sleep: deps.sleep,
261
+ isLoaded: () => launchdIsLoaded(DAEMON_LAUNCHD_LABEL, deps.run ?? defaultRunCommand, host.uid),
262
+ files: {
263
+ existsSync: fs.existsSync,
264
+ readFileSync: (file) => fs.readFileSync(file, "utf8"),
265
+ writeFileSync: fs.writeFileSync,
266
+ chmodSync: fs.chmodSync,
267
+ renameSync: fs.renameSync,
268
+ unlinkSync: fs.unlinkSync,
269
+ },
270
+ });
271
+ }
272
+ catch (err) {
273
+ return {
274
+ kind: plan.kind,
275
+ unitPath: plan.unitPath,
276
+ installed: fs.existsSync(plan.unitPath),
277
+ running: launchdIsLoaded(DAEMON_LAUNCHD_LABEL, deps.run ?? defaultRunCommand, host.uid),
278
+ error: err instanceof Error ? err.message : String(err),
279
+ };
280
+ }
281
+ if (!plist.loaded)
282
+ return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: false };
283
+ }
284
+ else if (plan.kind === "schtasks")
255
285
  fs.writeFileSync(plan.unitPath, encodeWindowsTaskXml(plan.rendered));
256
286
  else
257
287
  fs.writeFileSync(plan.unitPath, plan.rendered, { mode: plan.kind === "systemd-system" ? 0o644 : 0o600 });
258
288
  try {
259
- activate(plan, host, deps.run, deps.sleep);
289
+ if (plan.kind !== "launchd")
290
+ activate(plan, host, deps.run, deps.sleep);
260
291
  return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: true };
261
292
  }
262
293
  catch (err) {
@@ -10,6 +10,7 @@ import { botsRoot, botDir, launchdLabel } from "../../bot/paths.js";
10
10
  import { readBotConfig } from "../../bot/config.js";
11
11
  import { isPidAlive, readBotStatus } from "../../bot/status.js";
12
12
  import { nodeBinaryProblem } from "../../node-binary.js";
13
+ import { launchdPlistProgramArguments, parseLaunchdCommand } from "../../service-manager/launchd.js";
13
14
  export const BOTS_FAMILY_ID = "bots";
14
15
  export const BOTS_FAMILY_TITLE = "Local bots";
15
16
  const DEFAULT_DEPS = {
@@ -35,14 +36,8 @@ export function checkBots(_context, deps = DEFAULT_DEPS) {
35
36
  ];
36
37
  }
37
38
  }
38
- function launchdPlistArgs(plist) {
39
- const block = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
40
- if (!block)
41
- return [];
42
- return [...block[1].matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1]);
43
- }
44
39
  function launchdPlistHqBinary(plist) {
45
- return launchdPlistArgs(plist)[1] ?? null;
40
+ return parseLaunchdCommand(launchdPlistProgramArguments(plist))?.hq ?? null;
46
41
  }
47
42
  function runChecks(deps) {
48
43
  const home = deps.home();
@@ -122,7 +117,7 @@ function runChecks(deps) {
122
117
  try {
123
118
  const text = fs.readFileSync(plistPath, "utf8");
124
119
  hqPath = launchdPlistHqBinary(text);
125
- nodePath = launchdPlistArgs(text)[0] ?? null;
120
+ nodePath = parseLaunchdCommand(launchdPlistProgramArguments(text))?.node ?? null;
126
121
  }
127
122
  catch {
128
123
  hqPath = null;
@@ -100,6 +100,7 @@ export declare function buildOutpostLaneListCommand(input?: {
100
100
  laneUlids?: readonly string[];
101
101
  active?: boolean;
102
102
  hierarchy?: string | readonly string[];
103
+ senior?: string;
103
104
  snapshot?: boolean;
104
105
  }): string;
105
106
  /**
@@ -93,6 +93,8 @@ export function buildOutpostLaneListCommand(input = {}) {
93
93
  ? [input.hierarchy]
94
94
  : input.hierarchy ?? [])
95
95
  args.push("--hierarchy", laneId);
96
+ if (input.senior !== undefined)
97
+ args.push("--senior", input.senior);
96
98
  if (input.snapshot)
97
99
  args.push("--snapshot");
98
100
  return withRemoteHqDir(joinCommandParts(args));
@@ -23,7 +23,7 @@ import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, verifyHqR
23
23
  import { defaultPidLockDeps, pidLockStatus, readPidLock } from "./pid-lock.js";
24
24
  import { retireLegacyListen } from "./legacy-listen.js";
25
25
  import { readDaemonState } from "./state.js";
26
- import { activateLaunchd, defaultRunCommand, launchdIsLoaded, launchdServiceTarget, launchdPlistProgramArguments, launchdProgramArgumentsDiffer, parseLaunchctlPrintArguments, } from "../../../service-manager/launchd.js";
26
+ import { activateLaunchd, defaultRunCommand, discoverLaunchdIdentity, installLaunchdPlist, launchdProgramArguments, launchdIsLoaded, launchdServiceTarget, launchdPlistProgramArguments, parseLaunchdCommand, launchdProgramArgumentsDiffer, parseLaunchctlPrintArguments, } from "../../../service-manager/launchd.js";
27
27
  import { activateSystemd, systemdIsActive } from "../../../service-manager/systemd.js";
28
28
  import { nodeBinaryProblem, preferStableNodeBinary } from "../../../node-binary.js";
29
29
  /**
@@ -428,9 +428,7 @@ export function installedDaemonNodeBinary(paths, platform, readUnit = (file) =>
428
428
  const text = readUnit(paths.plistPath);
429
429
  if (!text)
430
430
  return null;
431
- const block = text.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
432
- const first = block?.[1]?.match(/<string>([^<]*)<\/string>/);
433
- return first?.[1] ?? null;
431
+ return parseLaunchdCommand(launchdPlistProgramArguments(text))?.node ?? null;
434
432
  }
435
433
  if (platform === "linux") {
436
434
  const text = readUnit(paths.systemdUnitPath);
@@ -544,19 +542,21 @@ function safeUsername() {
544
542
  }
545
543
  }
546
544
  export function renderLaunchdPlist(paths) {
545
+ const identity = discoverLaunchdIdentity(paths.home);
546
+ const args = launchdProgramArguments({
547
+ nodeBinary: paths.nodeBinary,
548
+ hqBinary: paths.hqBinary,
549
+ launchdLauncherBinary: identity.launcherBinary,
550
+ }, ["mesh", "daemon", "run"]);
547
551
  return `<?xml version="1.0" encoding="UTF-8"?>
548
552
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
549
553
  <plist version="1.0">
550
554
  <dict>
551
555
  <key>Label</key>
552
556
  <string>${LAUNCHD_LABEL}</string>
553
- <key>ProgramArguments</key>
557
+ ${identity.associatedBundleIdentifier ? ` <key>AssociatedBundleIdentifiers</key>\n <array>\n <string>${escapeXml(identity.associatedBundleIdentifier)}</string>\n </array>\n` : ""} <key>ProgramArguments</key>
554
558
  <array>
555
- <string>${escapeXml(paths.nodeBinary)}</string>
556
- <string>${escapeXml(paths.hqBinary)}</string>
557
- <string>mesh</string>
558
- <string>daemon</string>
559
- <string>run</string>
559
+ ${args.map((arg) => ` <string>${escapeXml(arg)}</string>`).join("\n")}
560
560
  </array>
561
561
  <key>WorkingDirectory</key>
562
562
  <string>${escapeXml(paths.daemonDir)}</string>
@@ -920,11 +920,24 @@ export function installDaemonService(opts) {
920
920
  if (platform === "darwin") {
921
921
  const rendered = renderLaunchdPlist(paths);
922
922
  mkdirSync(paths.launchAgentsDir, { recursive: true });
923
- replaceUnitFile(paths.plistPath, rendered, 0o600, () => {
924
- if (opts.run)
925
- activateLaunchd(paths.plistPath, opts.run, opts.uid, { sleep: opts.sleep });
926
- else
927
- opts.activate?.(platform, paths.plistPath);
923
+ installLaunchdPlist(paths.plistPath, LAUNCHD_LABEL, rendered, {
924
+ files: {
925
+ existsSync: fs.existsSync,
926
+ readFileSync: (file) => fs.readFileSync(file, "utf8"),
927
+ writeFileSync: fs.writeFileSync,
928
+ chmodSync: fs.chmodSync,
929
+ renameSync: fs.renameSync,
930
+ unlinkSync: fs.unlinkSync,
931
+ },
932
+ activate: Boolean(opts.run || opts.activate),
933
+ uid: opts.uid,
934
+ rollbackOnReloadError: true,
935
+ reload: () => {
936
+ if (opts.run)
937
+ activateLaunchd(paths.plistPath, opts.run, opts.uid, { sleep: opts.sleep });
938
+ else if (opts.activate)
939
+ opts.activate(platform, paths.plistPath);
940
+ },
928
941
  });
929
942
  return {
930
943
  platform,
@@ -15,9 +15,12 @@ export interface ServiceManagerDeps {
15
15
  platform?: ServicePlatform;
16
16
  run?: RunCommand;
17
17
  writeFileSync?: typeof fs.writeFileSync;
18
+ chmodSync?: typeof fs.chmodSync;
19
+ renameSync?: typeof fs.renameSync;
18
20
  mkdirSync?: typeof fs.mkdirSync;
19
21
  unlinkSync?: typeof fs.unlinkSync;
20
22
  existsSync?: typeof fs.existsSync;
23
+ readFileSync?: typeof fs.readFileSync;
21
24
  /** When false, render + write units but do not (de)activate them. */
22
25
  activate?: boolean;
23
26
  }
@@ -5,7 +5,7 @@
5
5
  * without touching the host's service manager.
6
6
  */
7
7
  import * as fs from "node:fs";
8
- import { activateLaunchd, deactivateLaunchd, defaultRunCommand, launchAgentsDir, launchdIsLoaded, launchdPlistPath, renderLaunchdPlist, } from "./launchd.js";
8
+ import { deactivateLaunchd, defaultRunCommand, launchAgentsDir, launchdIsLoaded, launchdPlistPath, installLaunchdPlist, renderLaunchdPlist, } from "./launchd.js";
9
9
  import { activateSystemd, deactivateSystemd, renderSystemdUserUnit, systemdIsActive, systemdUnitName, systemdUnitPath, systemdUserDir, } from "./systemd.js";
10
10
  import { detectServicePlatform, } from "./types.js";
11
11
  export { detectServicePlatform } from "./types.js";
@@ -23,6 +23,11 @@ export function installServices(specs, host, deps = {}) {
23
23
  const platform = deps.platform ?? detectServicePlatform();
24
24
  const run = deps.run ?? defaultRunCommand;
25
25
  const writeFileSync = deps.writeFileSync ?? fs.writeFileSync;
26
+ const chmodSync = deps.chmodSync ?? fs.chmodSync;
27
+ const renameSync = deps.renameSync ?? fs.renameSync;
28
+ const unlinkSync = deps.unlinkSync ?? fs.unlinkSync;
29
+ const existsSync = deps.existsSync ?? fs.existsSync;
30
+ const readFileSync = deps.readFileSync ?? fs.readFileSync;
26
31
  const mkdirSync = deps.mkdirSync ?? fs.mkdirSync;
27
32
  const activate = deps.activate ?? true;
28
33
  if (platform === "other") {
@@ -38,14 +43,25 @@ export function installServices(specs, host, deps = {}) {
38
43
  for (const spec of specs) {
39
44
  const unitPath = unitPathFor(platform, host.home, spec);
40
45
  const rendered = renderUnit(platform, spec, host);
41
- writeFileSync(unitPath, rendered, { mode: 0o600 });
46
+ if (platform === "darwin") {
47
+ const installed = installLaunchdPlist(unitPath, spec.label, rendered, {
48
+ run,
49
+ activate,
50
+ files: { existsSync, readFileSync: (file) => readFileSync(file, "utf8"), writeFileSync, chmodSync, renameSync, unlinkSync },
51
+ });
52
+ const unit = { name: spec.name, unitPath, rendered, installed: true };
53
+ if (activate)
54
+ unit.running = installed.loaded;
55
+ units.push(unit);
56
+ continue;
57
+ }
58
+ else {
59
+ writeFileSync(unitPath, rendered, { mode: 0o600 });
60
+ }
42
61
  const unit = { name: spec.name, unitPath, rendered, installed: true };
43
62
  if (activate) {
44
63
  try {
45
- if (platform === "darwin")
46
- activateLaunchd(unitPath, run);
47
- else
48
- activateSystemd(systemdUnitName(spec), run);
64
+ activateSystemd(systemdUnitName(spec), run);
49
65
  unit.running = true;
50
66
  }
51
67
  catch (err) {
@@ -2,11 +2,24 @@
2
2
  * macOS LaunchAgent rendering + activation for kit services.
3
3
  *
4
4
  * Units live at ~/Library/LaunchAgents/<label>.plist. Activation uses the
5
- * modern `launchctl bootstrap gui/<uid>` form (bootout first so a re-install
6
- * picks up a changed plist). Command execution is injected so tests never
7
- * touch launchctl.
5
+ * modern `launchctl bootstrap gui/<uid>` form only after a serialized plist
6
+ * changes. Command execution is injected so tests never touch launchctl.
8
7
  */
8
+ import * as fs from "node:fs";
9
9
  import { type ServiceHostPaths, type ServiceSpec } from "./types.js";
10
+ export interface LaunchdIdentity {
11
+ launcherBinary?: string;
12
+ associatedBundleIdentifier?: string;
13
+ }
14
+ /** Find the installed HQ bundle and read its identity from the generated plist. */
15
+ export declare function discoverLaunchdIdentity(home: string, io?: Pick<typeof fs, "existsSync" | "readFileSync">): LaunchdIdentity;
16
+ export declare function launchdProgramArguments(host: Pick<ServiceHostPaths, "nodeBinary" | "hqBinary" | "launchdLauncherBinary">, args: string[]): string[];
17
+ export declare function parseLaunchdCommand(args: string[]): {
18
+ node: string;
19
+ hq: string;
20
+ args: string[];
21
+ } | null;
22
+ export declare function launchdIdentityForHome(home: string): LaunchdIdentity;
10
23
  export declare function launchAgentsDir(home: string): string;
11
24
  export declare function launchdPlistPath(home: string, spec: Pick<ServiceSpec, "label">): string;
12
25
  export declare function renderLaunchdPlist(spec: ServiceSpec, host: ServiceHostPaths): string;
@@ -16,6 +29,30 @@ export type RunCommand = (cmd: string, args: string[]) => {
16
29
  stderr: string;
17
30
  };
18
31
  export type SleepFn = (ms: number) => void;
32
+ export interface LaunchdPlistFiles {
33
+ existsSync(file: string): boolean;
34
+ readFileSync(file: string, encoding: "utf8"): string;
35
+ writeFileSync(file: string, contents: string, options?: {
36
+ mode?: number;
37
+ }): unknown;
38
+ chmodSync?(file: string, mode: number): unknown;
39
+ unlinkSync?(file: string): unknown;
40
+ renameSync?(from: string, to: string): unknown;
41
+ }
42
+ /** Write and activate a LaunchAgent only when its serialized plist changes. */
43
+ export declare function installLaunchdPlist(plistPath: string, _label: string, rendered: string, deps: {
44
+ files: LaunchdPlistFiles;
45
+ run?: RunCommand;
46
+ uid?: number;
47
+ activate?: boolean;
48
+ reload?: () => unknown;
49
+ isLoaded?: () => boolean;
50
+ sleep?: SleepFn;
51
+ rollbackOnReloadError?: boolean;
52
+ }): {
53
+ changed: boolean;
54
+ loaded: boolean;
55
+ };
19
56
  /** How long to wait for a booted-out job to leave launchd before bootstrapping anyway. */
20
57
  export declare const LAUNCHD_UNLOAD_DEADLINE_MS = 10000;
21
58
  /** Interval between `launchctl print` polls while the old job unloads. */
@@ -2,15 +2,55 @@
2
2
  * macOS LaunchAgent rendering + activation for kit services.
3
3
  *
4
4
  * Units live at ~/Library/LaunchAgents/<label>.plist. Activation uses the
5
- * modern `launchctl bootstrap gui/<uid>` form (bootout first so a re-install
6
- * picks up a changed plist). Command execution is injected so tests never
7
- * touch launchctl.
5
+ * modern `launchctl bootstrap gui/<uid>` form only after a serialized plist
6
+ * changes. Command execution is injected so tests never touch launchctl.
8
7
  */
9
8
  import { spawnSync } from "node:child_process";
9
+ import * as fs from "node:fs";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
  import { escapeXml, } from "./types.js";
13
13
  const LAUNCHD_PATH_ENV = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
14
+ const HQ_TASK_ARGUMENT = "--hq-launchd-task";
15
+ /** Find the installed HQ bundle and read its identity from the generated plist. */
16
+ export function discoverLaunchdIdentity(home, io = fs) {
17
+ const bundles = ["/Applications/HQ.app", path.join(home, "Applications", "HQ.app")];
18
+ for (const bundle of bundles) {
19
+ const infoPath = path.join(bundle, "Contents", "Info.plist");
20
+ const executable = path.join(bundle, "Contents", "MacOS", "hq-sync-menubar");
21
+ if (!io.existsSync(infoPath))
22
+ continue;
23
+ const xml = io.readFileSync(infoPath, "utf8");
24
+ const identifier = /<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/.exec(xml)?.[1];
25
+ const version = /<key>HQLaunchdTaskVersion<\/key>\s*<integer>(\d+)<\/integer>/.exec(xml)?.[1];
26
+ if (!identifier)
27
+ throw new Error(`HQ.app Info.plist has no CFBundleIdentifier: ${infoPath}`);
28
+ if (version === "1" && !io.existsSync(executable)) {
29
+ throw new Error(`HQ.app advertises LaunchAgent launcher protocol 1 but its executable is missing: ${executable}`);
30
+ }
31
+ return {
32
+ associatedBundleIdentifier: identifier,
33
+ ...(version === "1" ? { launcherBinary: executable } : {}),
34
+ };
35
+ }
36
+ return {};
37
+ }
38
+ export function launchdProgramArguments(host, args) {
39
+ return host.launchdLauncherBinary
40
+ ? [host.launchdLauncherBinary, HQ_TASK_ARGUMENT, host.nodeBinary, host.hqBinary, ...args]
41
+ : [host.nodeBinary, host.hqBinary, ...args];
42
+ }
43
+ export function parseLaunchdCommand(args) {
44
+ const offset = args[1] === HQ_TASK_ARGUMENT ? 2 : 0;
45
+ const node = args[offset];
46
+ const hq = args[offset + 1];
47
+ if (!node || !hq)
48
+ return null;
49
+ return { node, hq, args: args.slice(offset + 2) };
50
+ }
51
+ export function launchdIdentityForHome(home) {
52
+ return discoverLaunchdIdentity(home);
53
+ }
14
54
  export function launchAgentsDir(home) {
15
55
  return path.join(home, "Library", "LaunchAgents");
16
56
  }
@@ -18,7 +58,10 @@ export function launchdPlistPath(home, spec) {
18
58
  return path.join(launchAgentsDir(home), `${spec.label}.plist`);
19
59
  }
20
60
  export function renderLaunchdPlist(spec, host) {
21
- const args = [host.nodeBinary, host.hqBinary, ...spec.args]
61
+ const discovered = discoverLaunchdIdentity(host.home);
62
+ const associatedBundleIdentifier = host.associatedBundleIdentifier ?? discovered.associatedBundleIdentifier;
63
+ const launcherBinary = host.launchdLauncherBinary ?? discovered.launcherBinary;
64
+ const args = launchdProgramArguments({ ...host, launchdLauncherBinary: launcherBinary }, spec.args)
22
65
  .map((a) => ` <string>${escapeXml(a)}</string>`)
23
66
  .join("\n");
24
67
  const env = {
@@ -36,7 +79,7 @@ export function renderLaunchdPlist(spec, host) {
36
79
  <dict>
37
80
  <key>Label</key>
38
81
  <string>${escapeXml(spec.label)}</string>
39
- <key>ProgramArguments</key>
82
+ ${associatedBundleIdentifier ? ` <key>AssociatedBundleIdentifiers</key>\n <array>\n <string>${escapeXml(associatedBundleIdentifier)}</string>\n </array>\n` : ""} <key>ProgramArguments</key>
40
83
  <array>
41
84
  ${args}
42
85
  </array>
@@ -60,6 +103,64 @@ ${envXml}
60
103
  </plist>
61
104
  `;
62
105
  }
106
+ /** Write and activate a LaunchAgent only when its serialized plist changes. */
107
+ export function installLaunchdPlist(plistPath, _label, rendered, deps) {
108
+ const { files } = deps;
109
+ const current = files.existsSync(plistPath) ? files.readFileSync(plistPath, "utf8") : null;
110
+ const isLoaded = () => deps.isLoaded?.() ?? (deps.run ? launchdIsLoaded(_label, deps.run, deps.uid) : false);
111
+ if (current === rendered) {
112
+ if (deps.activate === false)
113
+ return { changed: false, loaded: false };
114
+ if (isLoaded())
115
+ return { changed: false, loaded: true };
116
+ if (deps.reload)
117
+ deps.reload();
118
+ else if (deps.run)
119
+ activateLaunchd(plistPath, deps.run, deps.uid, { sleep: deps.sleep });
120
+ return { changed: false, loaded: isLoaded() };
121
+ }
122
+ const write = (contents) => {
123
+ if (!files.renameSync) {
124
+ files.writeFileSync(plistPath, contents, { mode: 0o600 });
125
+ files.chmodSync?.(plistPath, 0o600);
126
+ return;
127
+ }
128
+ const tempPath = `${plistPath}.${process.pid}.tmp`;
129
+ try {
130
+ files.writeFileSync(tempPath, contents, { mode: 0o600 });
131
+ files.chmodSync?.(tempPath, 0o600);
132
+ files.renameSync(tempPath, plistPath);
133
+ }
134
+ catch (error) {
135
+ try {
136
+ files.unlinkSync?.(tempPath);
137
+ }
138
+ catch (cleanupError) {
139
+ throw new AggregateError([error, cleanupError], `Could not clean temporary LaunchAgent plist ${tempPath}`);
140
+ }
141
+ throw error;
142
+ }
143
+ };
144
+ write(rendered);
145
+ if (deps.activate === false)
146
+ return { changed: true, loaded: false };
147
+ try {
148
+ if (deps.reload)
149
+ deps.reload();
150
+ else if (deps.run)
151
+ activateLaunchd(plistPath, deps.run, deps.uid, { sleep: deps.sleep });
152
+ }
153
+ catch (error) {
154
+ if (deps.rollbackOnReloadError) {
155
+ if (current === null)
156
+ files.unlinkSync?.(plistPath);
157
+ else
158
+ write(current);
159
+ }
160
+ throw error;
161
+ }
162
+ return { changed: true, loaded: isLoaded() };
163
+ }
63
164
  /** How long to wait for a booted-out job to leave launchd before bootstrapping anyway. */
64
165
  export const LAUNCHD_UNLOAD_DEADLINE_MS = 10_000;
65
166
  /** Interval between `launchctl print` polls while the old job unloads. */
@@ -0,0 +1,12 @@
1
+ export declare const LAUNCHD_MIGRATION_MARKER = ".launchd-node-migration-v1";
2
+ export declare const DEFERRED_MIGRATION_RETRY_MS: number;
3
+ export interface LaunchdMigrationMarker {
4
+ version: 1;
5
+ checkedAt: string;
6
+ deferredLabels: string[];
7
+ }
8
+ export declare function launchdMigrationMarkerPath(home: string): string;
9
+ /** Old timestamp-only markers mean the previous migration completed successfully. */
10
+ export declare function shouldRunLaunchdMigration(home: string, now?: number): boolean;
11
+ export declare function writeLaunchdMigrationMarker(home: string, deferredLabels: string[], now?: number): void;
12
+ //# sourceMappingURL=migration-state.d.ts.map
@@ -0,0 +1,46 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ export const LAUNCHD_MIGRATION_MARKER = ".launchd-node-migration-v1";
4
+ export const DEFERRED_MIGRATION_RETRY_MS = 24 * 60 * 60 * 1000;
5
+ export function launchdMigrationMarkerPath(home) {
6
+ return path.join(home, "Library", "Application Support", "Indigo HQ", LAUNCHD_MIGRATION_MARKER);
7
+ }
8
+ /** Old timestamp-only markers mean the previous migration completed successfully. */
9
+ export function shouldRunLaunchdMigration(home, now = Date.now()) {
10
+ const marker = launchdMigrationMarkerPath(home);
11
+ if (!fs.existsSync(marker))
12
+ return true;
13
+ try {
14
+ const raw = fs.readFileSync(marker, "utf8");
15
+ let parsed;
16
+ try {
17
+ parsed = JSON.parse(raw);
18
+ }
19
+ catch {
20
+ return Number.isNaN(Date.parse(raw.trim()));
21
+ }
22
+ if (!parsed || typeof parsed !== "object")
23
+ return true;
24
+ const state = parsed;
25
+ if (state.version !== 1 || typeof state.checkedAt !== "string")
26
+ return true;
27
+ if (!Array.isArray(state.deferredLabels) || state.deferredLabels.length === 0)
28
+ return false;
29
+ const checkedAt = Date.parse(state.checkedAt);
30
+ return !Number.isFinite(checkedAt) || now - checkedAt >= DEFERRED_MIGRATION_RETRY_MS;
31
+ }
32
+ catch {
33
+ return true;
34
+ }
35
+ }
36
+ export function writeLaunchdMigrationMarker(home, deferredLabels, now = Date.now()) {
37
+ const marker = launchdMigrationMarkerPath(home);
38
+ fs.mkdirSync(path.dirname(marker), { recursive: true, mode: 0o700 });
39
+ const state = {
40
+ version: 1,
41
+ checkedAt: new Date(now).toISOString(),
42
+ deferredLabels: [...new Set(deferredLabels)].sort(),
43
+ };
44
+ fs.writeFileSync(marker, `${JSON.stringify(state)}\n`, { mode: 0o600 });
45
+ }
46
+ //# sourceMappingURL=migration-state.js.map
@@ -0,0 +1,22 @@
1
+ import * as fs from "node:fs";
2
+ import { type RunCommand, type SleepFn } from "./launchd.js";
3
+ export interface LaunchdMigrationOptions {
4
+ home: string;
5
+ run: RunCommand;
6
+ uid: number;
7
+ launcherBinary?: string;
8
+ associatedBundleIdentifier?: string;
9
+ files?: Pick<typeof fs, "existsSync" | "readFileSync" | "writeFileSync" | "renameSync" | "unlinkSync" | "readdirSync" | "mkdirSync">;
10
+ processArgs?: string[];
11
+ xpcServiceName?: string;
12
+ sleep?: SleepFn;
13
+ }
14
+ export interface LaunchdMigrationResult {
15
+ migrated: string[];
16
+ backupsRemoved: string[];
17
+ deferred: string[];
18
+ alreadyDone: boolean;
19
+ }
20
+ /** First-CLI-run cleanup for HQ's old Node-first LaunchAgents and backup plists. */
21
+ export declare function migrateLegacyNodeLaunchAgents(options: LaunchdMigrationOptions): LaunchdMigrationResult;
22
+ //# sourceMappingURL=migration.d.ts.map
@@ -0,0 +1,85 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { launchAgentsDir, activateLaunchd, launchdPlistProgramArguments, } from "./launchd.js";
4
+ import { isVersionedNodePath, preferStableNodeBinary } from "../node-binary.js";
5
+ import { preferStableHqBinary } from "../mesh/live/daemon/install.js";
6
+ import { launchdMigrationMarkerPath, shouldRunLaunchdMigration, writeLaunchdMigrationMarker } from "./migration-state.js";
7
+ const HQ_LABEL_PREFIXES = ["ai.indigo.hq-", "ai.getindigo.hq-"];
8
+ function xml(value) {
9
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
10
+ }
11
+ function plistArgs(args) {
12
+ return ` <key>ProgramArguments</key>\n <array>\n${args.map((arg) => ` <string>${xml(arg)}</string>`).join("\n")}\n </array>`;
13
+ }
14
+ function isNodeProgram(program) {
15
+ return /(^|[\\/])node(?:\.exe)?$/i.test(program);
16
+ }
17
+ /** First-CLI-run cleanup for HQ's old Node-first LaunchAgents and backup plists. */
18
+ export function migrateLegacyNodeLaunchAgents(options) {
19
+ const files = options.files ?? fs;
20
+ const marker = launchdMigrationMarkerPath(options.home);
21
+ if (files.existsSync(marker) && !shouldRunLaunchdMigration(options.home)) {
22
+ return { migrated: [], backupsRemoved: [], deferred: [], alreadyDone: true };
23
+ }
24
+ const dir = launchAgentsDir(options.home);
25
+ const migrated = [];
26
+ const backupsRemoved = [];
27
+ const deferred = [];
28
+ if (!files.existsSync(dir))
29
+ files.mkdirSync(dir, { recursive: true });
30
+ for (const name of files.readdirSync(dir)) {
31
+ const fullPath = path.join(dir, name);
32
+ if (name.startsWith("ai.indigo.") && name.includes(".plist.bak-")) {
33
+ files.unlinkSync(fullPath);
34
+ backupsRemoved.push(name);
35
+ continue;
36
+ }
37
+ if (!name.endsWith(".plist") || !HQ_LABEL_PREFIXES.some((prefix) => name.startsWith(prefix)))
38
+ continue;
39
+ const source = files.readFileSync(fullPath, "utf8");
40
+ const args = launchdPlistProgramArguments(source);
41
+ if (args.length < 2 || !isNodeProgram(args[0]))
42
+ continue;
43
+ const label = name.slice(0, -".plist".length);
44
+ const currentProcessIsJob = options.xpcServiceName === label ||
45
+ (options.processArgs?.length === args.length && options.processArgs.every((arg, index) => arg === args[index]));
46
+ if (currentProcessIsJob) {
47
+ deferred.push(label);
48
+ continue;
49
+ }
50
+ if (!options.launcherBinary) {
51
+ deferred.push(name.slice(0, -".plist".length));
52
+ continue;
53
+ }
54
+ const nodeBinary = preferStableNodeBinary(args[0]);
55
+ if (isVersionedNodePath(nodeBinary)) {
56
+ deferred.push(name.slice(0, -".plist".length));
57
+ continue;
58
+ }
59
+ const hqBinary = preferStableHqBinary(args[1]);
60
+ const nextArgs = [options.launcherBinary, "--hq-launchd-task", nodeBinary, hqBinary, ...args.slice(2)];
61
+ const renderedArgs = plistArgs(nextArgs);
62
+ let updated = source.replace(/<key>ProgramArguments<\/key>\s*<array>[\s\S]*?<\/array>/, renderedArgs);
63
+ if (options.associatedBundleIdentifier && !/<key>AssociatedBundleIdentifiers<\/key>/.test(updated)) {
64
+ const attribution = ` <key>AssociatedBundleIdentifiers</key>\n <array>\n <string>${xml(options.associatedBundleIdentifier)}</string>\n </array>\n`;
65
+ updated = updated.replace(/(<key>Label<\/key>\s*<string>[^<]+<\/string>\s*)/, `$1${attribution}`);
66
+ }
67
+ if (updated === source)
68
+ throw new Error(`Could not safely rewrite ${fullPath}`);
69
+ const tmp = `${fullPath}.${process.pid}.migration.tmp`;
70
+ files.writeFileSync(tmp, updated, { mode: 0o600 });
71
+ files.renameSync(tmp, fullPath);
72
+ try {
73
+ activateLaunchd(fullPath, options.run, options.uid, { sleep: options.sleep });
74
+ }
75
+ catch (error) {
76
+ throw new Error(`Could not activate migrated HQ LaunchAgent ${name}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
77
+ }
78
+ migrated.push(label);
79
+ }
80
+ // A deferred/self/unsupported item is retried at most daily, rather than
81
+ // forcing every ordinary CLI invocation to rescan the LaunchAgents folder.
82
+ writeLaunchdMigrationMarker(options.home, deferred);
83
+ return { migrated, backupsRemoved, deferred, alreadyDone: false };
84
+ }
85
+ //# sourceMappingURL=migration.js.map
@@ -0,0 +1,24 @@
1
+ import type { RunCommand } from "./launchd.js";
2
+ export interface LaunchdStartupMigrationOptions {
3
+ home: string;
4
+ uid: number;
5
+ run?: RunCommand;
6
+ processArgs: string[];
7
+ xpcServiceName?: string;
8
+ }
9
+ export interface LaunchdStartupMigrationDependencies {
10
+ shouldRun?: (home: string) => boolean | Promise<boolean>;
11
+ discover?: (home: string) => {
12
+ launcherBinary?: string;
13
+ associatedBundleIdentifier?: string;
14
+ };
15
+ migrate?: (options: LaunchdStartupMigrationOptions & {
16
+ launcherBinary?: string;
17
+ associatedBundleIdentifier?: string;
18
+ }) => unknown;
19
+ markDeferred?: (home: string) => void;
20
+ warn?: (message: string) => void;
21
+ }
22
+ /** Best-effort migration; a compatibility cleanup never blocks the requested command. */
23
+ export declare function runLaunchdMigrationAtStartup(options: LaunchdStartupMigrationOptions, dependencies?: LaunchdStartupMigrationDependencies): Promise<void>;
24
+ //# sourceMappingURL=startup-migration.d.ts.map
@@ -0,0 +1,27 @@
1
+ /** Best-effort migration; a compatibility cleanup never blocks the requested command. */
2
+ export async function runLaunchdMigrationAtStartup(options, dependencies = {}) {
3
+ try {
4
+ const state = await import("./migration-state.js");
5
+ const shouldRun = dependencies.shouldRun ?? state.shouldRunLaunchdMigration;
6
+ if (!await shouldRun(options.home))
7
+ return;
8
+ const launchd = await import("./launchd.js");
9
+ const migration = await import("./migration.js");
10
+ const discover = dependencies.discover ?? launchd.discoverLaunchdIdentity;
11
+ const migrate = dependencies.migrate ?? migration.migrateLegacyNodeLaunchAgents;
12
+ migrate({ ...options, run: options.run ?? launchd.defaultRunCommand, ...discover(options.home) });
13
+ }
14
+ catch {
15
+ try {
16
+ if (dependencies.markDeferred)
17
+ dependencies.markDeferred(options.home);
18
+ else {
19
+ const state = await import("./migration-state.js");
20
+ state.writeLaunchdMigrationMarker(options.home, ["migration-error"]);
21
+ }
22
+ }
23
+ catch { /* Keep the requested command available even if state cannot be saved. */ }
24
+ (dependencies.warn ?? console.warn)("hq: legacy LaunchAgent migration failed; continuing");
25
+ }
26
+ }
27
+ //# sourceMappingURL=startup-migration.js.map
@@ -26,6 +26,9 @@ export interface ServiceHostPaths {
26
26
  home: string;
27
27
  nodeBinary: string;
28
28
  hqBinary: string;
29
+ /** Optional signed HQ.app launcher and bundle attribution for macOS. */
30
+ launchdLauncherBinary?: string;
31
+ associatedBundleIdentifier?: string;
29
32
  }
30
33
  export type ServicePlatform = "darwin" | "linux" | "other";
31
34
  export declare function detectServicePlatform(platform?: NodeJS.Platform): ServicePlatform;
package/dist/main.js CHANGED
@@ -166,6 +166,28 @@ program.hook("preAction", async () => {
166
166
  });
167
167
  });
168
168
  export async function runCli() {
169
+ const xpcServiceName = process.env.XPC_SERVICE_NAME ?? "";
170
+ const launchedByHqAgent = xpcServiceName.startsWith("ai.indigo.") || xpcServiceName.startsWith("ai.getindigo.");
171
+ const isDaemonEntryPoint = process.argv[2] === "daemon" && process.argv[3] === "run";
172
+ const isBotHotPath = process.argv[2] === "bot" && process.argv[3] === "run";
173
+ if (process.platform === "darwin" && typeof process.getuid === "function" &&
174
+ !launchedByHqAgent && !isDaemonEntryPoint && !isBotHotPath) {
175
+ const home = os.homedir();
176
+ // The startup helper checks its small marker before importing launchd or
177
+ // inspecting the app bundle. Linux never imports this Darwin-only path.
178
+ try {
179
+ const { runLaunchdMigrationAtStartup } = await import("./lib/service-manager/startup-migration.js");
180
+ await runLaunchdMigrationAtStartup({
181
+ home,
182
+ uid: process.getuid(),
183
+ processArgs: process.argv,
184
+ xpcServiceName,
185
+ });
186
+ }
187
+ catch {
188
+ console.warn("hq: legacy LaunchAgent migration failed; continuing");
189
+ }
190
+ }
169
191
  const foregroundNetworkBudget = createForegroundNetworkBudget();
170
192
  activeForegroundNetworkBudget = foregroundNetworkBudget;
171
193
  // Login must be usable before any identity or company is available, so do
@@ -4,4 +4,8 @@ export declare function nativeFastRouteEnabled(argv: readonly string[], env?: No
4
4
  export declare function fastFlagEnabled(flagKey: string, env?: NodeJS.ProcessEnv, options?: {
5
5
  numericEnvironmentOverride?: boolean;
6
6
  }): Promise<boolean>;
7
+ /** Reads a fail-safe flag that stays enabled unless a fresh value disables it. */
8
+ export declare function fastFlagEnabledDefaultOn(flagKey: string, env?: NodeJS.ProcessEnv, options?: {
9
+ numericEnvironmentOverride?: boolean;
10
+ }): Promise<boolean>;
7
11
  //# sourceMappingURL=native-fast-route.d.ts.map
@@ -28,6 +28,26 @@ export async function fastFlagEnabled(flagKey, env = process.env, options = {})
28
28
  return false;
29
29
  }
30
30
  }
31
+ /** Reads a fail-safe flag that stays enabled unless a fresh value disables it. */
32
+ export async function fastFlagEnabledDefaultOn(flagKey, env = process.env, options = {}) {
33
+ try {
34
+ const override = nativeRouteOverride(flagKey, env, options.numericEnvironmentOverride !== false);
35
+ if (override !== null)
36
+ return override;
37
+ const [{ resolveProcessFlagCompanyContext }, { readFlagSnapshotFile }] = await Promise.all([
38
+ import("./lib/process-flag-company-context.js"),
39
+ import("./lib/flag-snapshot-file.js"),
40
+ ]);
41
+ const { companyUid } = resolveProcessFlagCompanyContext(env);
42
+ if (!companyUid)
43
+ return true;
44
+ return readFlagSnapshotFile(companyUid, { env })?.flags[flagKey] !== false;
45
+ }
46
+ catch {
47
+ // This flag is a kill switch: missing scope or cache data must leave the feature enabled.
48
+ return true;
49
+ }
50
+ }
31
51
  /** The route's documented CLI override also accepts the conventional 1/0. */
32
52
  function nativeRouteOverride(flagKey, env, numericEnvironmentOverride) {
33
53
  const override = readLocalOverride(flagKey, env);
@@ -41,7 +41,7 @@ export const RESIDENT_PACKAGE_MODULES = {
41
41
  ],
42
42
  "bot-logs-follow": [],
43
43
  mcp: ["../lib/mcp/tools.js", "../lib/mcp/prompts.js", "../lib/anywhere-person-setting.js", "../utils/cognito-session.js", "@indigoai-us/hq-cloud", "../sentry.js", "../lib/doctor/client-health-reason-codes-flag.js", "@indigoai-us/hq-flags-client"],
44
- childWrapper: ["../main.js"],
44
+ childWrapper: ["../main.js", "../lib/service-manager/startup-migration.js"],
45
45
  coreJobRun: ["../lib/flag-registry.js"],
46
46
  "daemon-call": ["../lib/daemon/protocol.js"],
47
47
  daemon: [
@@ -200,6 +200,7 @@ const LOADERS = {
200
200
  ],
201
201
  childWrapper: [
202
202
  () => import("../main.js"),
203
+ () => import("../lib/service-manager/startup-migration.js"),
203
204
  ],
204
205
  coreJobRun: [
205
206
  () => import("../lib/flag-registry.js"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.50",
3
+ "version": "5.345.51",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {