@indigoai-us/hq-cli 5.345.45 → 5.345.46

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.46] — 2026-10-08
6
+ - 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`.
7
+ - `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`.
8
+ - 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.
9
+ - 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.
10
+ - Sync restart checks now count settled restarts across the full one-hour window and leave out only the unsettled tail.
11
+ - `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.
12
+
5
13
  ## [5.345.45] — 2026-10-07
6
14
  - 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
15
 
@@ -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). */
@@ -78,6 +79,8 @@ export interface GlobalCommandOpts {
78
79
  /** Root-discovery seams used by command tests and wrappers. */
79
80
  home?: string;
80
81
  cwd?: string;
82
+ /** Test seam for trusting the Codex hooks a global install registers. */
83
+ trustCodexHooks?: (cwd: string, keys: readonly string[]) => Promise<RuntimeHookTrustResult>;
81
84
  }
82
85
  export declare function resolveInstallRoot(explicit: string | undefined, options?: {
83
86
  env?: NodeJS.ProcessEnv;
@@ -23,10 +23,11 @@ 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 { 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
31
  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
32
  /** True when an hq daemon unit (launchd plist or systemd unit) exists. */
32
33
  export function isDaemonInstalled(home, platform = process.platform, queryTask = (task) => spawnSync('schtasks.exe', ['/Query', '/TN', task], { encoding: 'utf8', windowsHide: true }).status === 0) {
@@ -1074,8 +1075,9 @@ export async function runInstallGlobal(opts) {
1074
1075
  }
1075
1076
  }
1076
1077
  const install = runtime === 'codex' ? installGlobalCodex : installGlobalClaude;
1078
+ const hqRoot = resolveInstallRoot(opts.hqRoot, { env, cwd: opts.cwd, home: opts.home });
1077
1079
  install({
1078
- hqRoot: resolveInstallRoot(opts.hqRoot, { env, cwd: opts.cwd, home: opts.home }),
1080
+ hqRoot,
1079
1081
  home: opts.home,
1080
1082
  dryRun: opts.dryRun,
1081
1083
  via: opts.via,
@@ -1084,6 +1086,26 @@ export async function runInstallGlobal(opts) {
1084
1086
  env,
1085
1087
  log: opts.log,
1086
1088
  });
1089
+ if (runtime === 'codex' && !opts.dryRun)
1090
+ await trustInstalledCodexHooks(hqRoot, opts);
1091
+ }
1092
+ /**
1093
+ * Codex skips untrusted hooks, so the hooks.json entries written above do
1094
+ * nothing until they are trusted. Trust exactly those entries; on failure keep
1095
+ * the install and tell the user how to finish.
1096
+ */
1097
+ async function trustInstalledCodexHooks(hqRoot, opts) {
1098
+ const log = opts.log ?? ((l) => console.log(l));
1099
+ const home = resolveEnv({ home: opts.home }).home;
1100
+ const hooksJson = codexTargets(home).hooksJson;
1101
+ const keys = codexHookTrustKeys(JSON.parse(fs.readFileSync(hooksJson, 'utf-8')), hooksJson, hqRoot);
1102
+ const result = await (opts.trustCodexHooks ?? trustCodexUserHooks)(home, keys);
1103
+ if (result.status === 'trusted')
1104
+ log(` hooks: trusted ${result.trusted} HQ hook(s) in Codex`);
1105
+ else if (result.status === 'failed') {
1106
+ log(` hooks: Codex hooks are not trusted yet (${result.reason ?? 'unknown error'}), so Codex will skip them.`);
1107
+ log(' To finish, open Codex and approve the HQ hooks with /hooks, or re-run `hq install --global --runtime codex`.');
1108
+ }
1087
1109
  }
1088
1110
  export function registerUninstallCommand(program) {
1089
1111
  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';
@@ -155,6 +155,31 @@ export function removeCodexHooks(doc, hqRoot) {
155
155
  next.hooks = hooks;
156
156
  return next;
157
157
  }
158
+ /**
159
+ * Codex trust keys (`<hooks.json>:<snake_event>:<group>:<hook>`) for the HQ
160
+ * entries in a hooks.json document. Foreign entries are left out so install
161
+ * never trusts a hook the user added themselves.
162
+ */
163
+ export function codexHookTrustKeys(doc, hooksJsonPath, hqRoot) {
164
+ const hooks = doc.hooks;
165
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks))
166
+ return [];
167
+ const keys = [];
168
+ for (const [event, value] of Object.entries(hooks)) {
169
+ if (!Array.isArray(value))
170
+ continue;
171
+ const snake = event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
172
+ value.forEach((group, g) => {
173
+ if (!group || !Array.isArray(group.hooks))
174
+ return;
175
+ group.hooks.forEach((entry, h) => {
176
+ if (isAdapterEntry(entry, hqRoot))
177
+ keys.push(`${hooksJsonPath}:${snake}:${g}:${h}`);
178
+ });
179
+ });
180
+ }
181
+ return keys;
182
+ }
158
183
  export function hasAllCodexHooks(doc, hqRoot, target = 'master') {
159
184
  return addCodexHooks(doc, hqRoot, target) === doc;
160
185
  }
@@ -53,6 +53,13 @@ export interface RuntimeHookTrustResult {
53
53
  export declare function createCodexAppServerClient(cwd: string, executable?: string, args?: string[]): Promise<CodexRpcClient>;
54
54
  /** Trust only hooks declared by this HQ root's project `.codex/` layer. */
55
55
  export declare function trustCodexProjectHooks(hqRoot: string, deps?: HookTrustDependencies): Promise<RuntimeHookTrustResult>;
56
+ /**
57
+ * Trust the user-scope hooks `hq install --global --runtime codex` registered
58
+ * in ~/.codex/hooks.json, named by their Codex keys (`<file>:<event>:<group>:<hook>`).
59
+ * Codex skips untrusted hooks, so without this HQ stays inert in every session
60
+ * outside the HQ folder. Other hooks in the same file are never trusted here.
61
+ */
62
+ export declare function trustCodexUserHooks(cwd: string, keys: readonly string[], deps?: HookTrustDependencies): Promise<RuntimeHookTrustResult>;
56
63
  /**
57
64
  * Mark this HQ root as a trusted Claude Code workspace.
58
65
  *
@@ -181,10 +181,37 @@ export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
181
181
  if (!fs.existsSync(path.join(hqRoot, '.codex'))) {
182
182
  return { runtime: 'codex', status: 'skipped', trusted: 0, reason: 'project .codex layer absent' };
183
183
  }
184
+ return trustCodexHooks(hqRoot, deps, (hooks) => {
185
+ const projectHooks = hqProjectHooks(hqRoot, hooks);
186
+ return projectHooks.length === 0
187
+ ? { skip: 'no HQ project hooks discovered' }
188
+ : { hooks: projectHooks };
189
+ });
190
+ }
191
+ /**
192
+ * Trust the user-scope hooks `hq install --global --runtime codex` registered
193
+ * in ~/.codex/hooks.json, named by their Codex keys (`<file>:<event>:<group>:<hook>`).
194
+ * Codex skips untrusted hooks, so without this HQ stays inert in every session
195
+ * outside the HQ folder. Other hooks in the same file are never trusted here.
196
+ */
197
+ export async function trustCodexUserHooks(cwd, keys, deps = DEFAULT_DEPS) {
198
+ if (keys.length === 0) {
199
+ return { runtime: 'codex', status: 'skipped', trusted: 0, reason: 'no HQ user hooks registered' };
200
+ }
201
+ return trustCodexHooks(cwd, deps, (hooks) => {
202
+ const byKey = new Map(hooks.filter((hook) => hook.source === 'user' && !hook.isManaged).map((hook) => [hook.key, hook]));
203
+ const missing = keys.filter((key) => !byKey.has(key));
204
+ if (missing.length > 0)
205
+ return { fail: `Codex did not discover: ${missing.join(', ')}` };
206
+ return { hooks: keys.map((key) => byKey.get(key)) };
207
+ });
208
+ }
209
+ /** List hooks for `cwd`, trust and enable the selected ones, then verify. */
210
+ async function trustCodexHooks(cwd, deps, select) {
184
211
  let client;
185
212
  try {
186
- client = await deps.createCodexClient(hqRoot);
187
- const discovered = hooksFromListResponse(await client.request('hooks/list', { cwds: [hqRoot] }));
213
+ client = await deps.createCodexClient(cwd);
214
+ const discovered = hooksFromListResponse(await client.request('hooks/list', { cwds: [cwd] }));
188
215
  if (discovered.errors.length > 0) {
189
216
  return {
190
217
  runtime: 'codex',
@@ -193,19 +220,18 @@ export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
193
220
  reason: discovered.errors.join('; '),
194
221
  };
195
222
  }
196
- const projectHooks = hqProjectHooks(hqRoot, discovered.hooks);
197
- if (projectHooks.length === 0) {
198
- return {
199
- runtime: 'codex',
200
- status: 'skipped',
201
- trusted: 0,
202
- reason: 'no HQ project hooks discovered',
203
- };
223
+ const selection = select(discovered.hooks);
224
+ if ('skip' in selection) {
225
+ return { runtime: 'codex', status: 'skipped', trusted: 0, reason: selection.skip };
226
+ }
227
+ if ('fail' in selection) {
228
+ return { runtime: 'codex', status: 'failed', trusted: 0, reason: selection.fail };
204
229
  }
205
- // Converge every HQ project hook to trusted AND enabled. A hook that is
230
+ const selected = selection.hooks;
231
+ // Converge every selected HQ hook to trusted AND enabled. A hook that is
206
232
  // already trusted but was toggled off would otherwise silently stay
207
- // disabled forever — reindex is the convergence point, so it re-enables.
208
- const pending = projectHooks.filter((hook) => hook.trustStatus === 'untrusted' || hook.trustStatus === 'modified' || !hook.enabled);
233
+ // disabled forever; reindex and install are the convergence points.
234
+ const pending = selected.filter((hook) => hook.trustStatus === 'untrusted' || hook.trustStatus === 'modified' || !hook.enabled);
209
235
  if (pending.length === 0) {
210
236
  return { runtime: 'codex', status: 'unchanged', trusted: 0 };
211
237
  }
@@ -220,7 +246,7 @@ export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
220
246
  edits: [{ keyPath: 'hooks.state', value: state, mergeStrategy: 'upsert' }],
221
247
  reloadUserConfig: true,
222
248
  });
223
- const verified = hooksFromListResponse(await client.request('hooks/list', { cwds: [hqRoot] }));
249
+ const verified = hooksFromListResponse(await client.request('hooks/list', { cwds: [cwd] }));
224
250
  const verifiedByKey = new Map(verified.hooks.map((hook) => [hook.key, hook]));
225
251
  const stillPending = pending
226
252
  .filter((hook) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.45",
3
+ "version": "5.345.46",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {