@indigoai-us/hq-cli 5.345.45 → 5.345.47

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,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.47] — 2026-10-08
6
+ - `hq files share` now shows the server's reason when a private folder conflicts with its shared folder glob, for example that the glob still has grants or that only its creator or a company admin can replace it.
7
+ - After `hq install --global`, unauthenticated `claude` and `codex` commands show provider-specific sign-in instructions; Codex HTTP 401 transport text is kept in the HQ log.
8
+ - The CLI now stops before updating when a separate pnpm global install shadows the running `hq` command on PATH, preventing an update from modifying the wrong install tree.
9
+ - Keep lanes resumable when a provider quota refusal includes a known reset, and retry them after reset when quota account failover is enabled. Record generic rate limits separately from account quotas, billing refusals, and auth failures.
10
+ - Windows delegate send parity tests now budget the Bash fallback and reuse the runtime probe.
11
+
12
+ ## [5.345.46] — 2026-10-08
13
+ - Global Claude hooks installed by `hq install --global` now point `HQ_ROOT` and `CLAUDE_PROJECT_DIR` at the HQ root. Sessions outside the HQ folder now load HQ policies, and hook state stays inside HQ instead of creating a `workspace/` folder in the project. To update an existing install, re-run `hq install --global --runtime claude`.
14
+ - `hq install --global --runtime codex` now trusts the HQ hooks it registers in `~/.codex/hooks.json`. Codex skips untrusted hooks, so before this HQ hooks never ran in Codex sessions outside the HQ folder. Only HQ entries are trusted; other hooks in the file are left alone. If Codex cannot be reached, the install still completes and tells you to approve the hooks with `/hooks`.
15
+ - The local HQ MCP server (`hq mcp serve`) now uses your existing HQ sign-in when you have not run `hq mcp login`, so HQ tools work in Claude and Codex outside the HQ folder without a second browser login. This needs the matching hq-pro change that lets the hosted connector accept the HQ sign-in.
16
+ - The HQ background service no longer switches HQ Anywhere off when one re-read of your HQ Anywhere setting times out. It keeps the last value it read for up to 30 minutes; turning the setting off still applies on the next successful read.
17
+ - Sync restart checks now count settled restarts across the full one-hour window and leave out only the unsettled tail.
18
+ - `hq lanes stop` and `hq lanes reparent` now resolve lanes on registered Outposts and dispatch mutations to the owning host, including lanes without a primary mirror.
19
+
5
20
  ## [5.345.45] — 2026-10-07
6
21
  - A failed automatic CLI update now pauses automatic updates for 24 hours, including for newer releases, instead of retrying on every command. `hq rescue` still updates on request. Processes started by an update can no longer start another install, and update package-manager processes run with Corepack settings that cannot change your default pnpm version.
7
22
 
@@ -835,7 +835,10 @@ async function runDirectGrant(params) {
835
835
  console.error(chalk.red("ACL record not found — the prefix may not have an ACL yet"));
836
836
  }
837
837
  else if (res.status === 409 && body.code === "ACL_PATTERN_CONFLICT") {
838
- console.error(chalk.red(formatAclPatternConflict(canonicalPrefix, body.message)));
838
+ // hq-pro sends the specific reason (the sibling still has grants, or only
839
+ // its creator or an admin may replace it) in `error`; older servers used
840
+ // `message`. Show whichever is present instead of dropping it.
841
+ console.error(chalk.red(formatAclPatternConflict(canonicalPrefix, body.message ?? body.error)));
839
842
  }
840
843
  else if (res.status === 409) {
841
844
  console.error(chalk.red("Concurrent modification — please retry"));
@@ -1,5 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { AnywhereRuntimeDisabledError } from './anywhere-runtime-flag.js';
3
+ import { type RuntimeHookTrustResult } from '../utils/hook-trust.js';
3
4
  import { type HookTarget } from '../lib/install/claude-targets.js';
4
5
  export interface GlobalInstallOptions {
5
6
  /** HQ checkout to install from (absolute). */
@@ -21,6 +22,8 @@ export interface GlobalInstallOptions {
21
22
  via?: 'direct' | 'plugin';
22
23
  /** Local Claude Code marketplace directory (or HQ_CLAUDE_PLUGIN_MARKETPLACE). */
23
24
  marketplaceDir?: string;
25
+ /** Install the provider PATH wrappers after the hq-anywhere flag check. */
26
+ installProviderWrappers?: boolean;
24
27
  /** GitHub repository for the published HQ plugin marketplace. */
25
28
  marketplaceRepository?: string;
26
29
  /** Test seam for the Claude Code CLI; production uses `claude`. */
@@ -78,6 +81,8 @@ export interface GlobalCommandOpts {
78
81
  /** Root-discovery seams used by command tests and wrappers. */
79
82
  home?: string;
80
83
  cwd?: string;
84
+ /** Test seam for trusting the Codex hooks a global install registers. */
85
+ trustCodexHooks?: (cwd: string, keys: readonly string[]) => Promise<RuntimeHookTrustResult>;
81
86
  }
82
87
  export declare function resolveInstallRoot(explicit: string | undefined, options?: {
83
88
  env?: NodeJS.ProcessEnv;
@@ -23,10 +23,12 @@ import { grokConnectorInstructions } from '../lib/install/grok-targets.js';
23
23
  import { readRegistry, registryDir, registryFile, writeRegistry } from '../lib/registry/registry.js';
24
24
  import { readCompaniesManifestMap } from '../lib/work-context/repo-remote.js';
25
25
  import { resolveDefaultHqRoot } from '../utils/hq-root-resolver.js';
26
+ import { trustCodexUserHooks } from '../utils/hook-trust.js';
26
27
  import { DAEMON_LAUNCHD_LABEL, DAEMON_SYSTEMD_UNIT, DAEMON_WINDOWS_TASK, SYSTEM_UNIT_DIR } from '../lib/daemon/install.js';
27
28
  import { readDaemonConfig } from '../lib/daemon/config.js';
28
29
  import { daemonPaths } from '../lib/daemon/paths.js';
29
- import { addAgentsMdBlock, addCodexHooks, classifyLegacySkills, codexAdapterPath, codexCharterSource, codexTargets, hasAllCodexHooks, HQ_OWNED_MARKER, LEGACY_BACKUP_NAME, LEGACY_SKILLS_NAME, removeAgentsMdBlock, removeCodexHooks, } from '../lib/install/codex-targets.js';
30
+ import { assertProviderWrappersInstallable, installProviderWrappers, providerWrapperInstallPaths, removeProviderWrappers } from '../lib/install/provider-wrappers.js';
31
+ import { addAgentsMdBlock, addCodexHooks, classifyLegacySkills, codexAdapterPath, codexCharterSource, codexHookTrustKeys, codexTargets, hasAllCodexHooks, HQ_OWNED_MARKER, LEGACY_BACKUP_NAME, LEGACY_SKILLS_NAME, removeAgentsMdBlock, removeCodexHooks, } from '../lib/install/codex-targets.js';
30
32
  import { addClaudeMdBlock, addHqHooks, charterPath, claudeTargets, hasAllHqHooks, HQ_ANYWHERE_PACK, HQ_MCP_SERVER_NAME, hqSkillsSource, lineDiff, masterHookPath, shimPath, MCP_SUBPROCESS_PATH, removeClaudeMdBlock, removeHqHooks, SKILL_LINK_PREFIX, } from '../lib/install/claude-targets.js';
31
33
  /** True when an hq daemon unit (launchd plist or systemd unit) exists. */
32
34
  export function isDaemonInstalled(home, platform = process.platform, queryTask = (task) => spawnSync('schtasks.exe', ['/Query', '/TN', task], { encoding: 'utf8', windowsHide: true }).status === 0) {
@@ -347,7 +349,14 @@ export function installGlobalClaude(opts) {
347
349
  const env = opts.env ?? process.env;
348
350
  const log = opts.log ?? ((l) => console.log(l));
349
351
  const hqRoot = opts.hqRoot;
352
+ const providerBin = path.join(home, '.hq', 'bin');
353
+ // The wrappers are /bin/sh scripts reached through POSIX startup files, so a
354
+ // Windows shell would never pick them up. Skip them until a native shim exists.
355
+ const providerWrappersEnabled = process.platform !== 'win32'
356
+ && (opts.installProviderWrappers ?? isAnywhereEnabled(home, env));
350
357
  assertHqRoot(hqRoot);
358
+ if (providerWrappersEnabled)
359
+ assertProviderWrappersInstallable(providerBin);
351
360
  const t = claudeTargets(home);
352
361
  const prior = readState(t);
353
362
  const pluginSource = opts.via === 'plugin' ? resolveClaudePluginSource(opts.marketplaceDir, env, opts.marketplaceRepository) : undefined;
@@ -428,6 +437,8 @@ export function installGlobalClaude(opts) {
428
437
  if (pluginSource)
429
438
  touched.push(t.settingsJson);
430
439
  touched.push(regFile, t.stateFile);
440
+ if (providerWrappersEnabled)
441
+ touched.push(...providerWrapperInstallPaths(home));
431
442
  if (opts.dryRun) {
432
443
  log(`hq install --global --runtime claude --dry-run (HQ root ${hqRoot})`);
433
444
  log('Would write:');
@@ -559,6 +570,8 @@ export function installGlobalClaude(opts) {
559
570
  seeded: [...new Set([...(prior?.seeded ?? []), ...ownedSeeded])].sort(),
560
571
  };
561
572
  writeFileAtomic(t.stateFile, `${JSON.stringify(state, null, 2)}\n`);
573
+ if (providerWrappersEnabled)
574
+ installProviderWrappers(providerBin, home);
562
575
  log(`HQ installed for Claude Code from ${hqRoot}.`);
563
576
  log(` skills linked: ${skills.link.length} new, ${skills.already.length} already present, ${skills.skipped.length} skipped`);
564
577
  reportSkippedSkills(skills, log, false);
@@ -633,6 +646,9 @@ export function uninstallGlobalClaude(opts = {}) {
633
646
  }
634
647
  const pointer = readOrNull(t.hqPointer);
635
648
  const codexStillInstalled = fs.existsSync(codexTargets(home).stateFile);
649
+ const providerBin = path.join(home, '.hq', 'bin');
650
+ if (!codexStillInstalled)
651
+ touched.push(path.join(providerBin, 'claude'), path.join(providerBin, 'codex'));
636
652
  const pointerOwned = !codexStillInstalled && pointer !== null && pointer.trim() === hqRoot;
637
653
  if (pointerOwned)
638
654
  touched.push(t.hqPointer);
@@ -655,6 +671,8 @@ export function uninstallGlobalClaude(opts = {}) {
655
671
  if (pluginSource && state?.plugin?.marketplaceAdded) {
656
672
  runClaude(['marketplace', 'remove', '--scope', 'user', pluginSource.marketplaceName], home, env);
657
673
  }
674
+ if (!codexStillInstalled)
675
+ removeProviderWrappers(providerBin);
658
676
  unregisterClaudeServer({ name: HQ_MCP_SERVER_NAME, pack: HQ_ANYWHERE_PACK, env: { home } });
659
677
  if (fs.existsSync(t.settingsJson)) {
660
678
  writeConfigAtomic({
@@ -738,7 +756,14 @@ export function installGlobalCodex(opts) {
738
756
  const env = opts.env ?? process.env;
739
757
  const log = opts.log ?? ((l) => console.log(l));
740
758
  const hqRoot = opts.hqRoot;
759
+ const providerBin = path.join(home, '.hq', 'bin');
760
+ // The wrappers are /bin/sh scripts reached through POSIX startup files, so a
761
+ // Windows shell would never pick them up. Skip them until a native shim exists.
762
+ const providerWrappersEnabled = process.platform !== 'win32'
763
+ && (opts.installProviderWrappers ?? isAnywhereEnabled(home, env));
741
764
  assertCodexHqRoot(hqRoot);
765
+ if (providerWrappersEnabled)
766
+ assertProviderWrappersInstallable(providerBin);
742
767
  if (!isCodexInstalled({ home })) {
743
768
  throw Object.assign(new Error(`Codex is not installed (${path.join(home, '.codex')} is missing).`), {
744
769
  expected: true,
@@ -792,6 +817,8 @@ export function installGlobalCodex(opts) {
792
817
  if (tomlNext !== tomlDoc || legacyPackServerPresent)
793
818
  touched.push(t.configToml);
794
819
  touched.push(regFile, t.stateFile);
820
+ if (providerWrappersEnabled)
821
+ touched.push(...providerWrapperInstallPaths(home));
795
822
  if (opts.dryRun) {
796
823
  log(`hq install --global --runtime codex --dry-run (HQ root ${hqRoot})`);
797
824
  log('Would write:');
@@ -874,6 +901,8 @@ export function installGlobalCodex(opts) {
874
901
  seeded: [...new Set([...(prior?.seeded ?? []), ...ownedSeeded])].sort(),
875
902
  };
876
903
  writeFileAtomic(t.stateFile, `${JSON.stringify(state, null, 2)}\n`);
904
+ if (providerWrappersEnabled)
905
+ installProviderWrappers(providerBin, home);
877
906
  log(`HQ installed for Codex from ${hqRoot}.`);
878
907
  log(` skills linked: ${skills.link.length} new, ${skills.already.length} already present, ${skills.skipped.length} skipped`);
879
908
  reportSkippedSkills(skills, log, false);
@@ -894,8 +923,11 @@ export function uninstallGlobalCodex(opts = {}) {
894
923
  throw new Error('No HQ global install found (no install state and no ~/.hq/root).');
895
924
  const regFile = opts.registryFile ?? registryFile(registryDir(home, env));
896
925
  const claudeStillInstalled = fs.existsSync(claudeTargets(home).stateFile);
926
+ const providerBin = path.join(home, '.hq', 'bin');
897
927
  const packStillInstalled = fs.existsSync(path.join(hqRoot, 'core', 'packages', 'hq-anywhere', 'package.yaml'));
898
928
  const touched = [];
929
+ if (!claudeStillInstalled)
930
+ touched.push(path.join(providerBin, 'claude'), path.join(providerBin, 'codex'));
899
931
  const tomlDoc = readConfigDoc(t.configToml, codexConfigFormat).doc;
900
932
  const servers = tomlDoc.value.mcp_servers;
901
933
  if (!packStillInstalled && servers?.[HQ_MCP_SERVER_NAME]?._hqPack === HQ_ANYWHERE_PACK)
@@ -953,6 +985,8 @@ export function uninstallGlobalCodex(opts = {}) {
953
985
  }
954
986
  if (!packStillInstalled)
955
987
  unregisterCodexServer({ name: HQ_MCP_SERVER_NAME, pack: HQ_ANYWHERE_PACK, env: { home } });
988
+ if (!claudeStillInstalled)
989
+ removeProviderWrappers(providerBin);
956
990
  // Removal re-serializes the TOML (dropping comments). When what is left equals
957
991
  // the pre-install document, put the original bytes back.
958
992
  const tomlOriginal = state?.configToml.original ?? null;
@@ -1058,11 +1092,11 @@ export function resolveInstallRoot(explicit, options = {}) {
1058
1092
  export async function runInstallGlobal(opts) {
1059
1093
  const runtime = assertRuntime(opts.runtime);
1060
1094
  const env = opts.env ?? process.env;
1095
+ const enabled = await anywhereRuntimeAllowed(env, {
1096
+ ...(opts.personSettingReader ? { readSetting: opts.personSettingReader } : {}),
1097
+ onError: (name) => (opts.log ?? console.log)(`HQ Anywhere person setting unavailable (${name}); access remains off.`),
1098
+ });
1061
1099
  if (runtime === 'chatgpt' || runtime === 'grok' || opts.dryRun) {
1062
- const enabled = await anywhereRuntimeAllowed(env, {
1063
- ...(opts.personSettingReader ? { readSetting: opts.personSettingReader } : {}),
1064
- onError: (name) => (opts.log ?? console.log)(`HQ Anywhere person setting unavailable (${name}); access remains off.`),
1065
- });
1066
1100
  if (!enabled)
1067
1101
  throw new AnywhereRuntimeDisabledError();
1068
1102
  if (runtime === 'chatgpt' || runtime === 'grok') {
@@ -1074,16 +1108,38 @@ export async function runInstallGlobal(opts) {
1074
1108
  }
1075
1109
  }
1076
1110
  const install = runtime === 'codex' ? installGlobalCodex : installGlobalClaude;
1111
+ const hqRoot = resolveInstallRoot(opts.hqRoot, { env, cwd: opts.cwd, home: opts.home });
1077
1112
  install({
1078
- hqRoot: resolveInstallRoot(opts.hqRoot, { env, cwd: opts.cwd, home: opts.home }),
1113
+ hqRoot,
1079
1114
  home: opts.home,
1080
1115
  dryRun: opts.dryRun,
1081
1116
  via: opts.via,
1082
1117
  marketplaceDir: opts.marketplaceDir,
1083
1118
  marketplaceRepository: opts.marketplaceRepository,
1119
+ installProviderWrappers: enabled,
1084
1120
  env,
1085
1121
  log: opts.log,
1086
1122
  });
1123
+ if (runtime === 'codex' && !opts.dryRun)
1124
+ await trustInstalledCodexHooks(hqRoot, opts);
1125
+ }
1126
+ /**
1127
+ * Codex skips untrusted hooks, so the hooks.json entries written above do
1128
+ * nothing until they are trusted. Trust exactly those entries; on failure keep
1129
+ * the install and tell the user how to finish.
1130
+ */
1131
+ async function trustInstalledCodexHooks(hqRoot, opts) {
1132
+ const log = opts.log ?? ((l) => console.log(l));
1133
+ const home = resolveEnv({ home: opts.home }).home;
1134
+ const hooksJson = codexTargets(home).hooksJson;
1135
+ const keys = codexHookTrustKeys(JSON.parse(fs.readFileSync(hooksJson, 'utf-8')), hooksJson, hqRoot);
1136
+ const result = await (opts.trustCodexHooks ?? trustCodexUserHooks)(home, keys);
1137
+ if (result.status === 'trusted')
1138
+ log(` hooks: trusted ${result.trusted} HQ hook(s) in Codex`);
1139
+ else if (result.status === 'failed') {
1140
+ log(` hooks: Codex hooks are not trusted yet (${result.reason ?? 'unknown error'}), so Codex will skip them.`);
1141
+ log(' To finish, open Codex and approve the HQ hooks with /hooks, or re-run `hq install --global --runtime codex`.');
1142
+ }
1087
1143
  }
1088
1144
  export function registerUninstallCommand(program) {
1089
1145
  program
@@ -1856,11 +1856,13 @@ async function resolveCommandLaneTarget(ctx, laneId, options = {}) {
1856
1856
  const companyMatches = !hostlessUlidLookup || ownerLookupGate === true || (ownerLookupGate === "check-company" &&
1857
1857
  laneCompany !== undefined && laneCompany !== null &&
1858
1858
  await options.allowHostlessOwnerLookup(laneCompany));
1859
- if (lane.host.kind === "local" && ((!hostlessUlidLookup && lane.lane_id === laneId &&
1859
+ const exactOutpostOwnedMatch = !hostlessUlidLookup &&
1860
+ lane.lane_id === laneId && lane.host.kind === "outpost" && lane.host.id === outpostId;
1861
+ if (exactOutpostOwnedMatch || (lane.host.kind === "local" && ((!hostlessUlidLookup && lane.lane_id === laneId &&
1860
1862
  sanitizeHostComponent(lane.host.id) === hostSuffix) ||
1861
1863
  (hostlessUlidLookup && laneUlidPart(lane.lane_id) === requestedUlid &&
1862
1864
  sanitizeHostComponent(lane.host.id) === recordedSuffix &&
1863
- companyMatches))) {
1865
+ companyMatches)))) {
1864
1866
  matches.push({ outpostId, lane });
1865
1867
  }
1866
1868
  }
@@ -1914,7 +1916,7 @@ function commandLaneNotFound(laneId, outpostHint, details = {}) {
1914
1916
  const hint = outpostHint
1915
1917
  ? "; pass --outpost <id> to confirm a registered Outpost"
1916
1918
  : "";
1917
- return new LanesError("lane_not_found", `Lane ${laneId} was not found locally or on a registered Outpost with a matching host suffix${hint}`, { lane_id: laneId, ...details });
1919
+ return new LanesError("lane_not_found", `Could not resolve lane ${laneId}: no matching local record or reachable registered Outpost owner record was found${hint}`, { lane_id: laneId, ...details });
1918
1920
  }
1919
1921
  async function loadClaudeBalanceHostSnapshots(ctx, hqRoot, config, options = {}) {
1920
1922
  const localHost = laneHostFromCtx(ctx);
@@ -4931,7 +4933,9 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options, behavior =
4931
4933
  if (action === "reparent") {
4932
4934
  for (const descendantId of changedDescendantIds) {
4933
4935
  const ownerDescendant = await readOutpostLaneSnapshot(ctx, outpostId, descendantId);
4934
- descendants.push(syncLaneRecordFromOwner(ctx.hqRoot(), descendantId, outpostId, ownerDescendant, undefined, { allowNonTerminal: true }));
4936
+ descendants.push(behavior.syncPrimary === false
4937
+ ? dispatchedOwnerLane(ctx, descendantId, outpostId, ownerDescendant, behavior)
4938
+ : syncLaneRecordFromOwner(ctx.hqRoot(), descendantId, outpostId, ownerDescendant, undefined, { allowNonTerminal: true }));
4935
4939
  }
4936
4940
  }
4937
4941
  const questionCancellation = action === "resume" && lane.state === "awaiting_input" && behavior.afterFallbackResume
@@ -7515,11 +7519,21 @@ function archiveResumeMessage(hqRoot, laneId, message) {
7515
7519
  fs.writeFileSync(path.join(dir, `${message.message_id}.resume.jsonl`), `${JSON.stringify(message)}\n`, "utf8");
7516
7520
  }
7517
7521
  async function runReparent(ctx, laneId, opts) {
7518
- const hqRoot = ctx.hqRoot();
7519
7522
  if (!opts.senior) {
7520
7523
  throw new LanesError("missing_senior", "--senior is required", {});
7521
7524
  }
7522
- const initial = readLane(hqRoot, laneId);
7525
+ const target = await resolveCommandLaneTarget(ctx, laneId);
7526
+ const initial = target.lane;
7527
+ if (target.remoteOnly) {
7528
+ await dispatchOutpostLaneCommand(ctx, initial, "reparent", {
7529
+ senior: opts.senior,
7530
+ reason: opts.reason,
7531
+ }, {
7532
+ syncPrimary: false,
7533
+ expectedOwnerHost: target.ownerHost,
7534
+ });
7535
+ return;
7536
+ }
7523
7537
  if (initial.host.kind === "outpost" && !laneRunsHere(initial, reconcileHostFromCtx(ctx))) {
7524
7538
  await dispatchOutpostLaneCommand(ctx, initial, "reparent", {
7525
7539
  senior: opts.senior,
@@ -7527,7 +7541,7 @@ async function runReparent(ctx, laneId, opts) {
7527
7541
  });
7528
7542
  return;
7529
7543
  }
7530
- const result = await reparentOnOwner(ctx, laneId, opts);
7544
+ const result = await reparentOnOwner(ctx, initial.lane_id, opts);
7531
7545
  printJson({
7532
7546
  ok: true,
7533
7547
  action: "reparent",
@@ -8138,7 +8152,7 @@ async function dispatchPrimaryLaneMessage(ctx, laneId, options) {
8138
8152
  printJson(response);
8139
8153
  return response;
8140
8154
  }
8141
- async function dispatchOutpostLaneStop(ctx, lane, reason, quiet = false) {
8155
+ async function dispatchOutpostLaneStop(ctx, lane, reason, quiet = false, behavior = {}) {
8142
8156
  if (lane.host.kind !== "outpost") {
8143
8157
  throw new LanesError("outpost_dispatch_failed", `Lane ${lane.lane_id} has no Outpost host to dispatch stop to`, { lane_id: lane.lane_id });
8144
8158
  }
@@ -8170,6 +8184,14 @@ async function dispatchOutpostLaneStop(ctx, lane, reason, quiet = false) {
8170
8184
  const commandId = dispatched.commandId?.trim();
8171
8185
  const ownerError = ownerErrorFromOutput(dispatched.stdout);
8172
8186
  if (isDefiniteOutpostLaneNotFound(dispatched, lane.lane_id, ownerError)) {
8187
+ if (behavior.syncPrimary === false) {
8188
+ throw new LanesError("lane_not_found", ownerError.message, {
8189
+ ...ownerError.details,
8190
+ lane_id: lane.lane_id,
8191
+ outpost_id: outpostId,
8192
+ command_id: commandId,
8193
+ });
8194
+ }
8173
8195
  const at = ctx.now().toISOString();
8174
8196
  const updated = markRemoteLaneLost(ctx.hqRoot(), lane.lane_id, outpostId, at, "command", reason);
8175
8197
  if (updated.terminal_reason !== "remote_lost") {
@@ -8284,7 +8306,9 @@ async function dispatchOutpostLaneStop(ctx, lane, reason, quiet = false) {
8284
8306
  state = ownerLane.state;
8285
8307
  cancelledQuestions = parseRemoteCancelledQuestionIds(remote.cancelled_questions, lane.lane_id, outpostId, commandId);
8286
8308
  }
8287
- const updated = syncLaneRecordFromOwner(ctx.hqRoot(), lane.lane_id, outpostId, ownerLane);
8309
+ const updated = behavior.syncPrimary === false
8310
+ ? ownerLane
8311
+ : syncLaneRecordFromOwner(ctx.hqRoot(), lane.lane_id, outpostId, ownerLane);
8288
8312
  if (!quiet)
8289
8313
  printJson({
8290
8314
  ok: true,
@@ -8312,9 +8336,14 @@ async function runStop(ctx, laneId, reason, quiet = false) {
8312
8336
  if (operatorReason !== undefined)
8313
8337
  assertNoSecretText("stop reason", operatorReason);
8314
8338
  const hqRoot = ctx.hqRoot();
8315
- const lane = readLane(hqRoot, laneId);
8339
+ const target = await resolveCommandLaneTarget(ctx, laneId);
8340
+ const lane = target.lane;
8316
8341
  if (!quiet && lane.state !== "queued" && !isTerminalLaneState(lane.state)) {
8317
- process.stderr.write(stopTerminalWarning(laneId));
8342
+ process.stderr.write(stopTerminalWarning(lane.lane_id));
8343
+ }
8344
+ if (target.remoteOnly) {
8345
+ await dispatchOutpostLaneStop(ctx, lane, operatorReason, quiet, { syncPrimary: false });
8346
+ return;
8318
8347
  }
8319
8348
  if (lane.host.kind === "outpost" && !laneRunsHereForOperatorMutation(ctx, lane)) {
8320
8349
  await dispatchOutpostLaneStop(ctx, lane, operatorReason, quiet);
@@ -10,6 +10,17 @@ export interface ReadHqAnywherePersonSettingOptions {
10
10
  }
11
11
  /** Returns null when the person preference cannot be read; callers must fail closed. */
12
12
  export declare function readHqAnywherePersonSetting(options?: ReadHqAnywherePersonSettingOptions): Promise<boolean | null>;
13
+ /**
14
+ * Wrap a setting reader so a failed read (null) returns the last successfully
15
+ * read value for `graceMs`. A long-running caller such as hqd re-reads every
16
+ * minute, and one slow request would otherwise switch HQ Anywhere off for
17
+ * every session. A newly read value, including off, always applies at once;
18
+ * with no successful read inside the window the result stays null (closed).
19
+ */
20
+ export declare function stickyPersonSettingReader(read: () => Promise<boolean | null>, options: {
21
+ graceMs: number;
22
+ now?: () => number;
23
+ }): () => Promise<boolean | null>;
13
24
  export declare function evaluateAnywhereRuntime(flagEnabled: boolean, setting: boolean | null): {
14
25
  enabled: boolean;
15
26
  setting: boolean | null;
@@ -28,6 +28,25 @@ export async function readHqAnywherePersonSetting(options = {}) {
28
28
  return null;
29
29
  }
30
30
  }
31
+ /**
32
+ * Wrap a setting reader so a failed read (null) returns the last successfully
33
+ * read value for `graceMs`. A long-running caller such as hqd re-reads every
34
+ * minute, and one slow request would otherwise switch HQ Anywhere off for
35
+ * every session. A newly read value, including off, always applies at once;
36
+ * with no successful read inside the window the result stays null (closed).
37
+ */
38
+ export function stickyPersonSettingReader(read, options) {
39
+ const now = options.now ?? Date.now;
40
+ let last = null;
41
+ return async () => {
42
+ const value = await read();
43
+ if (value !== null) {
44
+ last = { value, at: now() };
45
+ return value;
46
+ }
47
+ return last && now() - last.at <= options.graceMs ? last.value : null;
48
+ };
49
+ }
31
50
  export function evaluateAnywhereRuntime(flagEnabled, setting) {
32
51
  return { enabled: flagEnabled && setting === true, setting };
33
52
  }
@@ -13,7 +13,16 @@ export interface McpToolResult {
13
13
  }
14
14
  export declare function jsonResult(value: unknown): McpToolResult;
15
15
  export declare function resolveHqMcpEndpoint(env?: NodeJS.ProcessEnv): URL;
16
- export declare function defaultMcpToken(): Promise<string>;
16
+ /** Test seams for {@link defaultMcpToken}. */
17
+ export interface McpTokenDeps {
18
+ tokenSource?: () => string;
19
+ mcpAudienceToken?: () => Promise<string>;
20
+ cognitoToken?: (options: {
21
+ tokenSource: "person" | "machine";
22
+ interactive: false;
23
+ }) => Promise<string>;
24
+ }
25
+ export declare function defaultMcpToken(deps?: McpTokenDeps): Promise<string>;
17
26
  export interface HqMcpBridgeOptions {
18
27
  endpoint?: URL;
19
28
  env?: NodeJS.ProcessEnv;
@@ -27,24 +27,31 @@ export function resolveHqMcpEndpoint(env = process.env) {
27
27
  throw error;
28
28
  }
29
29
  }
30
- export async function defaultMcpToken() {
31
- const { ensureCognitoToken, resolveCognitoTokenSource } = await import("../../../utils/cognito-session.js");
32
- if (resolveCognitoTokenSource() !== "machine") {
33
- const { getMcpAudienceToken, McpOAuthError } = await import("../mcp-oauth.js");
30
+ export async function defaultMcpToken(deps = {}) {
31
+ const session = deps.tokenSource && deps.cognitoToken ? null : await import("../../../utils/cognito-session.js");
32
+ const tokenSource = deps.tokenSource ?? session.resolveCognitoTokenSource;
33
+ const cognitoToken = deps.cognitoToken ?? ((options) => session.ensureCognitoToken(options));
34
+ if (tokenSource() !== "machine") {
35
+ const mcpAudienceToken = deps.mcpAudienceToken ?? (async () => (await import("../mcp-oauth.js")).getMcpAudienceToken());
34
36
  try {
35
- return await getMcpAudienceToken();
37
+ return await mcpAudienceToken();
36
38
  }
37
- catch (error) {
38
- if (error instanceof McpOAuthError)
39
- throw new McpOpError(error.code, error.message);
40
- throw new McpOpError("hq_mcp_auth_required", "Hosted MCP sign-in is required. Run `hq mcp login` and retry.");
39
+ catch {
40
+ // No `hq mcp login`: hosted hq-mcp also accepts the HQ sign-in client,
41
+ // so reuse the session hq-cli and the desktop app already hold.
42
+ }
43
+ try {
44
+ return await cognitoToken({ tokenSource: "person", interactive: false });
45
+ }
46
+ catch {
47
+ throw new McpOpError("hq_mcp_auth_required", "Sign in to HQ with `hq login`, then retry.");
41
48
  }
42
49
  }
43
50
  try {
44
51
  // Send this box's existing machine token. The hosted hq-mcp audience check
45
52
  // accepts it only when it was issued for hqMcpMachineClient; otherwise the
46
53
  // server rejects it. This helper does not inspect the credential's client ID.
47
- return await ensureCognitoToken({ tokenSource: "machine", interactive: false });
54
+ return await cognitoToken({ tokenSource: "machine", interactive: false });
48
55
  }
49
56
  catch {
50
57
  throw new McpOpError("hq_mcp_auth_required", "An hq-mcp machine token is unavailable. Configure this box with hq-pro's hqMcpMachineClient ID, then retry the daemon call.");
@@ -14,7 +14,7 @@ import { fileURLToPath } from "node:url";
14
14
  import { loadCachedTokens } from "@indigoai-us/hq-cloud";
15
15
  import { CLI_VERSION } from "../../cli-version.js";
16
16
  import { anywhereRuntimeEnabled } from "../../commands/anywhere-runtime-flag.js";
17
- import { anywhereRuntimeAllowed } from "../anywhere-person-setting.js";
17
+ import { anywhereRuntimeAllowed, readHqAnywherePersonSetting, stickyPersonSettingReader } from "../anywhere-person-setting.js";
18
18
  import { currentPackageUseLeaseInstall, currentPackageUseUpdateRequested } from "../../utils/package-use-lease.js";
19
19
  import { isLocalDependencyInstall, resolveRunningInstall } from "../../utils/version-gate.js";
20
20
  import { agentKitPaths } from "../agent-kit/paths.js";
@@ -77,6 +77,8 @@ export function handoffForPackageUpdateRequest(requested, restartDaemon) {
77
77
  const LEGACY_PROBE_TTL_MS = 60_000;
78
78
  /** A flag read that has not answered by then counts as off. */
79
79
  const HANDOFF_FLAG_TIMEOUT_MS = 10_000;
80
+ /** How long hqd keeps the last read HQ Anywhere setting when a re-read fails. */
81
+ const HQD_PERSON_SETTING_GRACE_MS = 30 * 60_000;
80
82
  export function syncRunnerSourceForPlan(stagedRunnerPath, hqCloud, legacyBundledRunnerPath = null, staged = { version: null }, currentSyncRunnerSource = null) {
81
83
  // A managed copy lives in the daemon directory, so it survives a CLI
82
84
  // replacement. Before lease release pickHqCloud returns it only when it is
@@ -838,13 +840,17 @@ export async function runDaemon(opts) {
838
840
  // hqd: the session/company socket every runtime talks to (hq-anywhere US-008).
839
841
  let hqdShutdown = null;
840
842
  let stopHeartbeat = () => { };
843
+ const readPersonSetting = stickyPersonSettingReader(() => readHqAnywherePersonSetting({
844
+ env,
845
+ onError: (name) => log("warn", `hqd person setting read failed (${name}); keeping the last read value for up to ${HQD_PERSON_SETTING_GRACE_MS / 60_000} minutes`),
846
+ }), { graceMs: HQD_PERSON_SETTING_GRACE_MS });
841
847
  const hqdListener = await startHqdRuntimeListener({
842
848
  isEnabled: async () => shouldStartHqd(await anywhereRuntimeAllowed(env, {
843
849
  refreshFlag: async () => {
844
850
  const { refreshFlagRegistrySnapshot } = await import("../flag-registry.js");
845
851
  await refreshFlagRegistrySnapshot(5_000);
846
852
  },
847
- onError: (name) => log("warn", `hqd person setting read failed (${name}); runtime remains off`),
853
+ readSetting: readPersonSetting,
848
854
  })),
849
855
  startServer: async () => {
850
856
  const startConfig = readDaemonConfig(paths.configPath);
@@ -1,5 +1,7 @@
1
1
  import type { CheckResult } from "../types.js";
2
2
  export declare const SYNC_RUNTIME_WINDOW_MS: number;
3
+ /** Ignore timing and restart signals until a newly started receiver has settled. */
4
+ export declare const SYNC_RUNTIME_RESTART_WARMUP_MS: number;
3
5
  export interface SyncRuntimeOptions {
4
6
  now: number;
5
7
  conflictCount?: number;
@@ -11,6 +11,8 @@ import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
  import { currentSyncLogFamily, SYNC_LOG_GENERATIONS, syncLogBasePaths, syncLogGenerationPath, } from "../../../utils/sync-log-sources.js";
13
13
  export const SYNC_RUNTIME_WINDOW_MS = 60 * 60_000;
14
+ /** Ignore timing and restart signals until a newly started receiver has settled. */
15
+ export const SYNC_RUNTIME_RESTART_WARMUP_MS = 20 * 60_000;
14
16
  const LAG_SECONDS = 15 * 60;
15
17
  const LAG_MIN_RECEIPTS = 3;
16
18
  const RESTART_MIN_STARTS = 4;
@@ -62,7 +64,9 @@ function lineTimestamp(line) {
62
64
  /** Classify the last hour of sync log lines into privacy-safe results. */
63
65
  export function analyzeSyncRuntimeLog(lines, options) {
64
66
  const since = options.now - SYNC_RUNTIME_WINDOW_MS;
65
- let slowReceipts = 0;
67
+ const contextSince = since - SYNC_RUNTIME_RESTART_WARMUP_MS;
68
+ const slowReceiptTimes = [];
69
+ const receiverStartTimes = [];
66
70
  let denied = 0;
67
71
  let starts = 0;
68
72
  let deviceLimited = 0;
@@ -70,35 +74,45 @@ export function analyzeSyncRuntimeLog(lines, options) {
70
74
  const time = lineTimestamp(line);
71
75
  if (time === null)
72
76
  continue;
73
- if (!Number.isFinite(time) || time < since || time > options.now)
77
+ if (!Number.isFinite(time) || time < contextSince || time > options.now)
74
78
  continue;
75
79
  const stamp = TIMESTAMP.exec(line);
76
80
  const rest = stamp ? line.slice(stamp[0].length) : line;
77
- if (DEVICE_LIMIT.test(rest))
81
+ if (time >= since && DEVICE_LIMIT.test(rest))
78
82
  deviceLimited += 1;
79
83
  const record = parseRecord(rest);
80
84
  if (!record)
81
85
  continue;
82
86
  const event = record.event;
83
- if (event === "fanout.receive") {
87
+ if (event === "receiver.start") {
88
+ receiverStartTimes.push(time);
89
+ const settledAt = time + SYNC_RUNTIME_RESTART_WARMUP_MS;
90
+ // The lower bound preserves a full hour of settled restarts; the upper
91
+ // bound applies the 20-minute settle rule and excludes only the tail.
92
+ if (settledAt >= since && settledAt <= options.now)
93
+ starts += 1;
94
+ }
95
+ else if (event === "fanout.receive" && time >= since) {
84
96
  const latency = record.latencySeconds;
85
97
  if (typeof latency === "number" && latency > LAG_SECONDS)
86
- slowReceipts += 1;
87
- }
88
- else if (event === "receiver.start") {
89
- starts += 1;
98
+ slowReceiptTimes.push(time);
90
99
  }
91
- if ((record.type === "receiver" || record.type === "error" ||
92
- (typeof event === "string" && event.startsWith("receiver."))) &&
100
+ if (time >= since &&
101
+ (record.type === "receiver" || record.type === "error" ||
102
+ (typeof event === "string" && event.startsWith("receiver."))) &&
93
103
  PERMISSION.test(JSON.stringify(record.err ?? record.message ?? ""))) {
94
104
  denied += 1;
95
105
  }
96
106
  }
107
+ // Startup can temporarily expose queued receive latency and restart events
108
+ // while the receiver is warming its backlog. Only judge receipts outside the
109
+ // warm-up interval following the most recent preceding start.
110
+ const slowReceipts = slowReceiptTimes.filter((time) => !receiverStartTimes.some((start) => start <= time && time - start < SYNC_RUNTIME_RESTART_WARMUP_MS)).length;
97
111
  const conflicts = options.conflictCount ?? 0;
98
112
  const checks = [
99
113
  { id: "realtime-lag", reasonCode: "realtime-lag-stuck", failing: slowReceipts >= LAG_MIN_RECEIPTS, count: slowReceipts, label: "incoming changes delayed over 15 minutes" },
100
114
  { id: "receiver-permission", reasonCode: "receiver-permission-denied", failing: denied > 0, count: denied, label: "cloud permission refusals" },
101
- { id: "runner-restarts", reasonCode: "runner-restart-loop", failing: starts >= RESTART_MIN_STARTS, count: starts, label: "sync runner starts" },
115
+ { id: "runner-restarts", reasonCode: "runner-restart-loop", failing: starts >= RESTART_MIN_STARTS, count: starts, label: "settled sync runner starts" },
102
116
  { id: "device-limit", reasonCode: "device-limit-backoff", failing: deviceLimited > 0, count: deviceLimited, label: "device-limit backoffs" },
103
117
  { id: "conflicts", reasonCode: "repeated-conflicts", failing: conflicts >= CONFLICT_MIN_COUNT, count: conflicts, label: "unresolved conflicts" },
104
118
  ];
@@ -197,7 +211,7 @@ export function defaultSyncRuntimeResults(now, hqHome = path.join(os.homedir(),
197
211
  const logFile = family === null ? paths.desktop : paths[family];
198
212
  let lines;
199
213
  try {
200
- lines = readWindowLines(logFile, options.now - SYNC_RUNTIME_WINDOW_MS, family === "daemon");
214
+ lines = readWindowLines(logFile, options.now - SYNC_RUNTIME_WINDOW_MS - SYNC_RUNTIME_RESTART_WARMUP_MS, family === "daemon");
201
215
  }
202
216
  catch {
203
217
  return [
@@ -50,9 +50,14 @@ export function hqSkillsSource(hqRoot) {
50
50
  /** The exact hook command registered for one event (absolute path, quoted). */
51
51
  export function hookCommand(hqRoot, event, target = 'master') {
52
52
  const skipInsideRoot = `if [ -d "\${CLAUDE_PROJECT_DIR:-}" ] && [ "\${CLAUDE_PROJECT_DIR}" -ef ${shellQuote(hqRoot)} ]; then exit 0; fi; cwd=$(pwd -P 2>/dev/null || pwd); while [ -n "$cwd" ]; do if [ -f "$cwd/core/core.yaml" ] && [ -f "$cwd/companies/manifest.yaml" ]; then exit 0; fi; [ "$cwd" = / ] && break; cwd=\${cwd%/*}; [ -n "$cwd" ] || cwd=/; done; `;
53
+ // Many HQ hooks take their root from CLAUDE_PROJECT_DIR (some before HQ_ROOT). In a
54
+ // foreign project that is the user's folder, so they load no policies and write
55
+ // workspace/ state there. Point both at HQ; the payload's cwd still names the
56
+ // folder the session is working in.
57
+ const rootEnv = `HQ_ROOT=${shellQuote(hqRoot)} CLAUDE_PROJECT_DIR=${shellQuote(hqRoot)} `;
53
58
  if (target === 'shim')
54
- return `${skipInsideRoot}exec /bin/sh ${shellQuote(shimPath(hqRoot))} ${shellQuote(event)} --runtime claude`;
55
- return `${skipInsideRoot}BASH_ENV=/dev/null exec bash ${shellQuote(masterHookPath(hqRoot))} ${shellQuote(event)}`;
59
+ return `${skipInsideRoot}${rootEnv}exec /bin/sh ${shellQuote(shimPath(hqRoot))} ${shellQuote(event)} --runtime claude`;
60
+ return `${skipInsideRoot}${rootEnv}BASH_ENV=/dev/null exec bash ${shellQuote(masterHookPath(hqRoot))} ${shellQuote(event)}`;
56
61
  }
57
62
  /** Quote one argument for the POSIX shell used by Claude Code command hooks. */
58
63
  function shellQuote(value) {
@@ -30,6 +30,12 @@ type Doc = Record<string, unknown>;
30
30
  export declare function addCodexHooks(doc: Doc, hqRoot: string, target?: HookTarget): Doc;
31
31
  /** Remove only HQ adapter entries; drop containers this left empty. */
32
32
  export declare function removeCodexHooks(doc: Doc, hqRoot: string): Doc;
33
+ /**
34
+ * Codex trust keys (`<hooks.json>:<snake_event>:<group>:<hook>`) for the HQ
35
+ * entries in a hooks.json document. Foreign entries are left out so install
36
+ * never trusts a hook the user added themselves.
37
+ */
38
+ export declare function codexHookTrustKeys(doc: Doc, hooksJsonPath: string, hqRoot: string): string[];
33
39
  export declare function hasAllCodexHooks(doc: Doc, hqRoot: string, target?: HookTarget): boolean;
34
40
  export type LegacySkillsMirror = {
35
41
  kind: 'absent';