@indigoai-us/hq-cli 5.107.1 → 5.108.1

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,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.108.1] — 2026-09-04
6
+
7
+ ## [5.108.0] — 2026-09-03
8
+
9
+ ### Fixed
10
+
11
+ - Every `hq` command was reading the whole local sync database before it ran,
12
+ to pull a single timestamp out of it for the client-health heartbeat. On a
13
+ machine whose sync store had grown large this cost around 21 seconds of CPU
14
+ per command — including every `hq secrets` fetch — and the 1.2-second bound
15
+ the heartbeat claims could not stop it, because the work is synchronous and
16
+ the timer cannot fire while it runs. The timestamp is now observed at most
17
+ once every 15 minutes, once per machine rather than once per process, and
18
+ sync commands still refresh it every time. Measured on an affected machine, a
19
+ credential fetch went from ~22.6 s to ~1.4 s.
20
+
5
21
  ## [5.107.1] — 2026-09-03
6
22
 
7
23
  ## [5.107.0] — 2026-09-03
@@ -30,6 +30,12 @@ export declare class SyncExitError extends Error {
30
30
  readonly code: number;
31
31
  constructor(code: number);
32
32
  }
33
+ /**
34
+ * Preserve the vault's typed write-scope refusal at the CLI boundary. This is
35
+ * deliberately duck-typed so hq-cli remains compatible while hq-cloud and the
36
+ * CLI are released independently.
37
+ */
38
+ export declare function formatSyncFailure(err: unknown): string;
33
39
  /**
34
40
  * US-003: run one sync fanout under a client-health attempt/outcome report.
35
41
  * The report is best-effort, bounded, and swallowed — it can only append a
@@ -17,7 +17,7 @@ import * as fs from "fs";
17
17
  import * as path from "path";
18
18
  import { share, sync, getStateDir, listJournals, loadCachedTokens, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, resolvePullScope, } from "@indigoai-us/hq-cloud";
19
19
  import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
20
- import { companyFolderExceedsThreshold, emitNarrowHint, isStrictRefusal, resolveBannerLevel, resolveNarrowHintMinBytes, } from "../lib/narrow-hint-banner.js";
20
+ import { companyFolderExceedsThreshold, emitNarrowHint, isStrictRefusal, resolveBannerLevel, resolveNarrowHintPresentationLevel, resolveNarrowHintMinBytes, } from "../lib/narrow-hint-banner.js";
21
21
  import { beginSyncHealthReport, } from "../utils/client-health.js";
22
22
  /**
23
23
  * Terminal-failure signal for the sync fanout runners. The runners
@@ -38,6 +38,29 @@ export class SyncExitError extends Error {
38
38
  this.name = "SyncExitError";
39
39
  }
40
40
  }
41
+ /**
42
+ * Preserve the vault's typed write-scope refusal at the CLI boundary. This is
43
+ * deliberately duck-typed so hq-cli remains compatible while hq-cloud and the
44
+ * CLI are released independently.
45
+ */
46
+ export function formatSyncFailure(err) {
47
+ if (typeof err === "object" && err !== null) {
48
+ const diagnostic = err;
49
+ if (diagnostic.code === "POLICY_WRITE_SCOPE_TRUNCATED") {
50
+ const dropped = Array.isArray(diagnostic.droppedWriteGrantPrefixes)
51
+ ? diagnostic.droppedWriteGrantPrefixes.filter((prefix) => typeof prefix === "string")
52
+ : [];
53
+ const retained = Array.isArray(diagnostic.retainedGrantPrefixes)
54
+ ? diagnostic.retainedGrantPrefixes.filter((prefix) => typeof prefix === "string")
55
+ : [];
56
+ return ("Vault credentials were refused because the requested write scope could not be represented exactly. " +
57
+ `Dropped write prefixes: ${dropped.length > 0 ? dropped.join(", ") : "unknown"}. ` +
58
+ `Retained prefixes: ${retained.length > 0 ? retained.join(", ") : "none"}. ` +
59
+ "No files were uploaded. Narrow or consolidate the grants, then retry.");
60
+ }
61
+ }
62
+ return err instanceof Error ? err.message : String(err);
63
+ }
41
64
  /**
42
65
  * US-003: run one sync fanout under a client-health attempt/outcome report.
43
66
  * The report is best-effort, bounded, and swallowed — it can only append a
@@ -283,10 +306,13 @@ export async function pullAll(options, deps) {
283
306
  const nudgeExceedsSize = resolvedMode === "all" &&
284
307
  entry.companyUid !== undefined &&
285
308
  narrowNudgeExceedsSize(options.hqRoot, entry.slug);
286
- if (resolvedMode === "all" &&
309
+ const strictRefusal = resolvedMode === "all" &&
287
310
  nudgeExceedsSize &&
288
311
  isStrictRefusal(resolvedMode, narrowHintLevel) &&
289
- !options.modeAllOverride &&
312
+ !options.modeAllOverride;
313
+ const bannerLevel = resolveNarrowHintPresentationLevel(narrowHintLevel, strictRefusal);
314
+ if (strictRefusal &&
315
+ resolvedMode === "all" &&
290
316
  entry.companyUid) {
291
317
  // Emit the strict-level banner once, then mark the leg as errored
292
318
  // without invoking sync(). The operator either narrows the
@@ -295,7 +321,7 @@ export async function pullAll(options, deps) {
295
321
  emitNarrowHint({
296
322
  companyUid: entry.companyUid,
297
323
  syncMode: resolvedMode,
298
- level: narrowHintLevel,
324
+ level: bannerLevel,
299
325
  });
300
326
  const message = "Refusing to pull all-mode membership in strict mode. " +
301
327
  "Run `hq sync narrow --apply` to migrate, or re-run with --mode-all.";
@@ -316,12 +342,12 @@ export async function pullAll(options, deps) {
316
342
  emitNarrowHint({
317
343
  companyUid: entry.companyUid,
318
344
  syncMode: resolvedMode,
319
- level: narrowHintLevel,
345
+ level: bannerLevel,
320
346
  });
321
347
  }
322
348
  }
323
349
  catch (err) {
324
- const message = err instanceof Error ? err.message : String(err);
350
+ const message = formatSyncFailure(err);
325
351
  result.errors.push({ company: entry.slug, message });
326
352
  result.perCompany.push({ slug: entry.slug, error: message });
327
353
  }
@@ -413,7 +439,7 @@ export async function pushAll(options, deps) {
413
439
  result.perCompany.push({ slug: entry.slug, result: r });
414
440
  }
415
441
  catch (err) {
416
- const message = err instanceof Error ? err.message : String(err);
442
+ const message = formatSyncFailure(err);
417
443
  result.errors.push({ company: entry.slug, message });
418
444
  result.perCompany.push({ slug: entry.slug, error: message });
419
445
  }
@@ -807,7 +833,7 @@ export function registerCloudCommands(program) {
807
833
  catch (err) {
808
834
  if (syncHealth)
809
835
  await syncHealth.failed();
810
- const message = err instanceof Error ? err.message : String(err);
836
+ const message = formatSyncFailure(err);
811
837
  if (jsonMode) {
812
838
  // In JSON mode, the parent process is parsing stderr for ndjson —
813
839
  // human-formatted error lines would corrupt the stream. Emit a
@@ -891,19 +917,22 @@ export function registerCloudCommands(program) {
891
917
  const nudgeExceedsSize = resolvedMode === "all" &&
892
918
  resolvedCompanyUid !== undefined &&
893
919
  narrowNudgeExceedsSize(options.hqRoot, options.company);
920
+ const strictRefusal = resolvedMode === "all" &&
921
+ nudgeExceedsSize &&
922
+ isStrictRefusal(resolvedMode, narrowHintLevel) &&
923
+ options.modeAll !== true;
924
+ const bannerLevel = resolveNarrowHintPresentationLevel(narrowHintLevel, strictRefusal);
894
925
  // Strict-mode refusal: matches runPullAll + runNowSingle behavior.
895
926
  // Default banner level is 'hint' which never triggers refusal —
896
927
  // wired now so a future release can flip the default to 'strict'
897
928
  // (still size-gated) without re-touching this command.
898
- if (resolvedMode === "all" &&
899
- nudgeExceedsSize &&
900
- isStrictRefusal(resolvedMode, narrowHintLevel) &&
901
- options.modeAll !== true &&
929
+ if (strictRefusal &&
930
+ resolvedMode === "all" &&
902
931
  resolvedCompanyUid) {
903
932
  emitNarrowHint({
904
933
  companyUid: resolvedCompanyUid,
905
934
  syncMode: resolvedMode,
906
- level: narrowHintLevel,
935
+ level: bannerLevel,
907
936
  });
908
937
  console.error(chalk.red("\n✗ Pull refused: strict narrow-hint mode is on and this " +
909
938
  "company's local folder has grown large. Run `hq sync narrow --apply` " +
@@ -941,7 +970,7 @@ export function registerCloudCommands(program) {
941
970
  emitNarrowHint({
942
971
  companyUid: resolvedCompanyUid,
943
972
  syncMode: resolvedMode,
944
- level: narrowHintLevel,
973
+ level: bannerLevel,
945
974
  });
946
975
  }
947
976
  await syncHealth.succeeded();
@@ -1406,15 +1435,18 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1406
1435
  const nudgeExceedsSize = resolvedMode === "all" &&
1407
1436
  resolvedCompanyUid !== undefined &&
1408
1437
  narrowNudgeExceedsSize(hqRoot, targetCompany);
1409
- if (resolvedMode === "all" &&
1438
+ const strictRefusal = resolvedMode === "all" &&
1410
1439
  nudgeExceedsSize &&
1411
1440
  isStrictRefusal(resolvedMode, narrowHintLevel) &&
1412
- !modeAllOverride &&
1441
+ !modeAllOverride;
1442
+ const bannerLevel = resolveNarrowHintPresentationLevel(narrowHintLevel, strictRefusal);
1443
+ if (strictRefusal &&
1444
+ resolvedMode === "all" &&
1413
1445
  resolvedCompanyUid) {
1414
1446
  emitNarrowHint({
1415
1447
  companyUid: resolvedCompanyUid,
1416
1448
  syncMode: resolvedMode,
1417
- level: narrowHintLevel,
1449
+ level: bannerLevel,
1418
1450
  });
1419
1451
  console.error(chalk.red("\n✗ Sync now refused: strict narrow-hint mode is on and this " +
1420
1452
  "company's local folder has grown large. Run `hq sync narrow --apply` " +
@@ -1468,7 +1500,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1468
1500
  emitNarrowHint({
1469
1501
  companyUid: resolvedCompanyUid,
1470
1502
  syncMode: resolvedMode,
1471
- level: narrowHintLevel,
1503
+ level: bannerLevel,
1472
1504
  });
1473
1505
  }
1474
1506
  console.log(chalk.green("\n✓ Sync now complete"));
@@ -52,6 +52,7 @@
52
52
  * first-class skip), and {@link registerMcpServers} is the pack-install routing
53
53
  * seam over it.
54
54
  */
55
+ import { type FlagReader } from '../lib/flag-registry.js';
55
56
  /** Base for every MCP-registration error; carries a stable machine-checkable `code`. */
56
57
  export declare abstract class McpRegistrationError extends Error {
57
58
  abstract readonly code: string;
@@ -595,6 +596,10 @@ export declare function registerServer(opts: RegisterServerOptions): RegisterSer
595
596
  * when neither a url nor a command is present.
596
597
  */
597
598
  export declare function manifestTarget(manifest: McpManifest): string;
599
+ /** Resolve the gate once at the start of an MCP registration operation. */
600
+ export declare function isMcpRegistrationEnabled(flagReader?: FlagReader): boolean;
601
+ /** Freeze a registration decision so multi-server work cannot split on refresh. */
602
+ export declare function captureMcpRegistrationDecision(flagReader?: FlagReader): FlagReader;
598
603
  /**
599
604
  * Register one pack's MCP servers into the shared agent configs (the public seam
600
605
  * `pack-install` routes `wire:'merge'` keys to). For each declared server name it
@@ -609,6 +614,7 @@ export declare function manifestTarget(manifest: McpManifest): string;
609
614
  * @param names the bare server names from `contributes.mcp`
610
615
  * @param options manifest loader + secret resolver + injectable env (all optional;
611
616
  * without a loader this throws, since US-007 has no payload-dir context)
617
+ *
612
618
  * @returns one {@link RegisterServerResult} per server, in `names` order
613
619
  */
614
620
  export declare function registerMcpServers(pkg: string, names: string[], options?: {
@@ -621,6 +627,8 @@ export declare function registerMcpServers(pkg: string, names: string[], options
621
627
  /** Lock tuning + backup stamp passthrough (tests). */
622
628
  lock?: AcquireLockOptions;
623
629
  stamp?: string;
630
+ /** Test seam: held registry snapshot reader. */
631
+ flagReader?: FlagReader;
624
632
  }): RegisterServerResult[];
625
633
  /** smol-toml's value-table type (its `parse` return + `stringify` input). */
626
634
  type TomlTable = Record<string, unknown>;
@@ -57,6 +57,7 @@ import * as fs from 'fs';
57
57
  import * as os from 'os';
58
58
  import * as path from 'path';
59
59
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
60
+ import { resolveFlagGate, resolveProcessFlagGate, } from '../lib/flag-registry.js';
60
61
  // ---------------------------------------------------------------------------
61
62
  // Named error classes (no bare catch-all anywhere in this module).
62
63
  //
@@ -1225,6 +1226,29 @@ export function manifestTarget(manifest) {
1225
1226
  }
1226
1227
  return manifest.url ?? '';
1227
1228
  }
1229
+ /** Registry vocabulary for the legacy MCP operator kill switch. */
1230
+ const MCP_REGISTRATION_LOOKUP = {
1231
+ globalEnvVar: 'HQ_DISABLE_MCP_REGISTRATION',
1232
+ globalValueSemantics: {
1233
+ onValues: [],
1234
+ offValues: ['1'],
1235
+ unrecognizedValue: true,
1236
+ unsetValue: true,
1237
+ },
1238
+ fallback: true,
1239
+ };
1240
+ const mcpRegistrationLegacyFallback = () => process.env.HQ_DISABLE_MCP_REGISTRATION !== '1';
1241
+ /** Resolve the gate once at the start of an MCP registration operation. */
1242
+ export function isMcpRegistrationEnabled(flagReader) {
1243
+ return flagReader
1244
+ ? resolveFlagGate(flagReader, 'cli.mcp-registration', MCP_REGISTRATION_LOOKUP, mcpRegistrationLegacyFallback)
1245
+ : resolveProcessFlagGate('cli.mcp-registration', MCP_REGISTRATION_LOOKUP, mcpRegistrationLegacyFallback);
1246
+ }
1247
+ /** Freeze a registration decision so multi-server work cannot split on refresh. */
1248
+ export function captureMcpRegistrationDecision(flagReader) {
1249
+ const enabled = isMcpRegistrationEnabled(flagReader);
1250
+ return { isEnabled: () => enabled };
1251
+ }
1228
1252
  /**
1229
1253
  * Register one pack's MCP servers into the shared agent configs (the public seam
1230
1254
  * `pack-install` routes `wire:'merge'` keys to). For each declared server name it
@@ -1239,6 +1263,7 @@ export function manifestTarget(manifest) {
1239
1263
  * @param names the bare server names from `contributes.mcp`
1240
1264
  * @param options manifest loader + secret resolver + injectable env (all optional;
1241
1265
  * without a loader this throws, since US-007 has no payload-dir context)
1266
+ *
1242
1267
  * @returns one {@link RegisterServerResult} per server, in `names` order
1243
1268
  */
1244
1269
  export function registerMcpServers(pkg, names, options) {
@@ -1250,14 +1275,14 @@ export function registerMcpServers(pkg, names, options) {
1250
1275
  // ORDERING: this is checked FIRST, BEFORE the loadManifest programmer-error guard
1251
1276
  // below. The kill-switch is a USER/OPERATOR condition; the missing-loadManifest
1252
1277
  // throw is a PROGRAMMER error. An operator who set the kill-switch must never hit a
1253
- // spurious McpManifestError, so the operator path wins. We read process.env directly
1254
- // (not options.env that's the SafeWriteEnv home-path injector, NOT process env);
1255
- // tests set/unset process.env.HQ_DISABLE_MCP_REGISTRATION around the call.
1278
+ // spurious McpManifestError, so the operator path wins. Its legacy environment
1279
+ // predicate remains the outage fallback; `options.env` is the SafeWriteEnv
1280
+ // home-path injector, not process env.
1256
1281
  //
1257
1282
  // Returns an empty RegisterServerResult[] (`[]`) — the correct "no servers
1258
1283
  // registered" semantic — so callers (pack-install) consume it gracefully. The skip
1259
1284
  // NOTICE is emitted exactly once per registerMcpServers call.
1260
- if (process.env.HQ_DISABLE_MCP_REGISTRATION === '1') {
1285
+ if (!isMcpRegistrationEnabled(options?.flagReader)) {
1261
1286
  process.stderr.write('MCP registration skipped (HQ_DISABLE_MCP_REGISTRATION=1)\n');
1262
1287
  return [];
1263
1288
  }
@@ -52,7 +52,7 @@ import { safeExtractTarball } from './safe-extract.js';
52
52
  import { getCompanyUid, vaultApiFetch, vaultApiFetchPublic, } from '../utils/vault-api.js';
53
53
  import { ensureCognitoToken } from '../utils/cognito-session.js';
54
54
  import { formatApiKeyCapabilityDenial, peekHqApiKey, } from '../utils/resolve-vault-credential.js';
55
- import { redactSecrets, SECRET_REDACTION, registerMcpServers, McpManifestError, } from './mcp-registration.js';
55
+ import { redactSecrets, SECRET_REDACTION, captureMcpRegistrationDecision, registerMcpServers, McpManifestError, } from './mcp-registration.js';
56
56
  import { listSecretCacheScopes } from '../utils/secrets-cache.js';
57
57
  import { loadRevealedSecrets } from './secrets.js';
58
58
  const PACK_UPDATE_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
@@ -1492,13 +1492,21 @@ async function wireMcpServers(pkg, destDir, company) {
1492
1492
  const loadManifest = (name) => loadMcpManifestFrom(destDir, name);
1493
1493
  const registered = [];
1494
1494
  const skipped = [];
1495
+ // A refresh may arrive while secret resolution awaits. Freeze the gate before
1496
+ // the loop so an install never writes only an arbitrary prefix of a pack.
1497
+ const flagReader = captureMcpRegistrationDecision();
1495
1498
  for (const name of pkg.contributes.mcp ?? []) {
1496
1499
  try {
1497
1500
  const resolveSecret = await makeInstallSecretResolver(collectManifestSecretNames(loadManifest(name)), company);
1498
1501
  // Per-server call: registerMcpServers throws on the FIRST unresolvable
1499
1502
  // secret, so calling it one name at a time lets us catch + continue.
1500
- registerMcpServers(pkg.name, [name], { loadManifest, resolveSecret });
1501
- registered.push(name);
1503
+ const results = registerMcpServers(pkg.name, [name], {
1504
+ loadManifest,
1505
+ resolveSecret,
1506
+ flagReader,
1507
+ });
1508
+ if (results.length > 0)
1509
+ registered.push(name);
1502
1510
  }
1503
1511
  catch (e) {
1504
1512
  const isUnresolvableSecret = e instanceof McpManifestError && /cannot resolve \$\{secret:/.test(e.message);
@@ -11,5 +11,20 @@ export declare function removeStaleHqWorktrees(hqRoot: string, now?: number, dea
11
11
  * malformed configurations remain untouched and receive recovery guidance.
12
12
  */
13
13
  export declare function repairExtremeHookDrift(hqRoot: string, allowRepair?: boolean): void;
14
+ /**
15
+ * Stop the generated project settings from silently downgrading an operator who
16
+ * chose Bypass mode globally.
17
+ *
18
+ * Claude Code resolves `permissions.defaultMode` project-over-user, so ANY value
19
+ * in the HQ root's `.claude/settings.json` — including HQ's own shipped "plan" —
20
+ * outranks `bypassPermissions` in `~/.claude/settings.json`. The operator sets
21
+ * bypass once at user scope and then every HQ session boots in some other mode
22
+ * with no indication why. When that user-scope choice is present, drop the
23
+ * project key so their own mode is what actually applies.
24
+ *
25
+ * `.claude/settings.local.json` is the operator's deliberate per-machine layer
26
+ * and outranks both scopes; it is never touched.
27
+ */
28
+ export declare function dropProjectDefaultModeForBypassUser(hqRoot: string): void;
14
29
  export declare function registerReindexCommand(program: Command): void;
15
30
  //# sourceMappingURL=reindex.d.ts.map
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { spawnSync } from 'node:child_process';
27
27
  import * as fs from 'node:fs';
28
+ import * as os from 'node:os';
28
29
  import * as path from 'node:path';
29
30
  import * as yaml from 'js-yaml';
30
31
  import { reindex, rescue } from '@indigoai-us/hq-cloud';
@@ -32,6 +33,7 @@ import { trustHqRuntimeHooks } from '../utils/hook-trust.js';
32
33
  import { findHqRoot } from '../utils/manifest.js';
33
34
  import { guardLargeFiles } from '../utils/large-file-guard.js';
34
35
  const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse'];
36
+ const BYPASS_MODE = 'bypassPermissions';
35
37
  const HOOK_CHECK_RELATIVE_PATH = path.join('core', 'scripts', 'check-hq-hooks.sh');
36
38
  const WORKTREE_STALE_AFTER_MS = 12 * 60 * 60 * 1_000;
37
39
  const WORKTREE_GIT_TIMEOUT_MS = 2_000;
@@ -470,6 +472,119 @@ export function repairExtremeHookDrift(hqRoot, allowRepair = true) {
470
472
  printHookHealthWarning(hqRoot, 'hook configuration repair could not run');
471
473
  }
472
474
  }
475
+ /**
476
+ * Resolve the user-scope Claude settings file — CLAUDE_CONFIG_DIR when the
477
+ * operator relocated their Claude config, otherwise ~/.claude/settings.json.
478
+ */
479
+ function userClaudeSettingsPath() {
480
+ const configDir = process.env.CLAUDE_CONFIG_DIR?.trim();
481
+ if (configDir)
482
+ return path.join(path.resolve(configDir), 'settings.json');
483
+ const home = process.env.HOME?.trim() || os.homedir();
484
+ if (!home)
485
+ return undefined;
486
+ return path.join(home, '.claude', 'settings.json');
487
+ }
488
+ /** Parse a settings file into a plain object, or undefined when it is absent or malformed. */
489
+ function readSettingsObject(file) {
490
+ let raw;
491
+ try {
492
+ raw = fs.readFileSync(file, 'utf8');
493
+ }
494
+ catch {
495
+ // Absent (or unreadable) user/project settings simply means there is no
496
+ // override to reconcile. Hook health reports a broken project file already.
497
+ return undefined;
498
+ }
499
+ try {
500
+ const parsed = JSON.parse(raw);
501
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
502
+ return undefined;
503
+ return parsed;
504
+ }
505
+ catch {
506
+ return undefined;
507
+ }
508
+ }
509
+ function permissionsObject(settings) {
510
+ const permissions = settings?.permissions;
511
+ if (!permissions || typeof permissions !== 'object' || Array.isArray(permissions))
512
+ return undefined;
513
+ return permissions;
514
+ }
515
+ function declaredDefaultMode(settings) {
516
+ const mode = permissionsObject(settings)?.defaultMode;
517
+ return typeof mode === 'string' ? mode : undefined;
518
+ }
519
+ /**
520
+ * Replace a file through a same-directory temporary file and a rename, so a
521
+ * failed or interrupted write can never leave a truncated `.claude/settings.json`
522
+ * behind — that file carries every hook registration HQ depends on.
523
+ */
524
+ function writeFileAtomically(target, contents) {
525
+ const temporary = path.join(path.dirname(target), `.${path.basename(target)}.reindex-${process.pid}.tmp`);
526
+ try {
527
+ fs.writeFileSync(temporary, contents);
528
+ // Keep whatever mode the operator had on the original; a fresh temp file
529
+ // would otherwise hand back umask defaults on every rewrite.
530
+ try {
531
+ fs.chmodSync(temporary, fs.statSync(target).mode & 0o777);
532
+ }
533
+ catch {
534
+ // A missing or unstattable original just means the default mode applies.
535
+ }
536
+ fs.renameSync(temporary, target);
537
+ }
538
+ catch (err) {
539
+ try {
540
+ fs.rmSync(temporary, { force: true });
541
+ }
542
+ catch {
543
+ // Best-effort cleanup; the original file is still intact either way.
544
+ }
545
+ throw err;
546
+ }
547
+ }
548
+ /**
549
+ * Stop the generated project settings from silently downgrading an operator who
550
+ * chose Bypass mode globally.
551
+ *
552
+ * Claude Code resolves `permissions.defaultMode` project-over-user, so ANY value
553
+ * in the HQ root's `.claude/settings.json` — including HQ's own shipped "plan" —
554
+ * outranks `bypassPermissions` in `~/.claude/settings.json`. The operator sets
555
+ * bypass once at user scope and then every HQ session boots in some other mode
556
+ * with no indication why. When that user-scope choice is present, drop the
557
+ * project key so their own mode is what actually applies.
558
+ *
559
+ * `.claude/settings.local.json` is the operator's deliberate per-machine layer
560
+ * and outranks both scopes; it is never touched.
561
+ */
562
+ export function dropProjectDefaultModeForBypassUser(hqRoot) {
563
+ // As with the hook repair: `hq reindex` can run from an arbitrary directory,
564
+ // and only a real HQ root's settings are ours to rewrite.
565
+ if (!isHqRoot(hqRoot))
566
+ return;
567
+ const userSettingsPath = userClaudeSettingsPath();
568
+ if (!userSettingsPath)
569
+ return;
570
+ if (declaredDefaultMode(readSettingsObject(userSettingsPath)) !== BYPASS_MODE)
571
+ return;
572
+ const settingsPath = path.join(hqRoot, '.claude', 'settings.json');
573
+ const settings = readSettingsObject(settingsPath);
574
+ const permissions = permissionsObject(settings);
575
+ const projectMode = declaredDefaultMode(settings);
576
+ if (!settings || !permissions || projectMode === undefined || projectMode === BYPASS_MODE)
577
+ return;
578
+ delete permissions.defaultMode;
579
+ try {
580
+ writeFileAtomically(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
581
+ }
582
+ catch (err) {
583
+ console.warn(`reindex: could not drop permissions.defaultMode "${projectMode}" from ${settingsPath} — ${err instanceof Error ? err.message : String(err)}\nHQ sessions will keep booting in "${projectMode}" instead of the bypassPermissions mode set in ${userSettingsPath}.`);
584
+ return;
585
+ }
586
+ console.log(`reindex: dropped permissions.defaultMode "${projectMode}" from .claude/settings.json — ${userSettingsPath} sets ${BYPASS_MODE}`);
587
+ }
473
588
  /**
474
589
  * Keep files above GitHub's 100MB limit out of the HQ repo, and say so.
475
590
  *
@@ -528,6 +643,11 @@ export function registerReindexCommand(program) {
528
643
  const { status } = reindex({ repoRoot: opts.repoRoot });
529
644
  repairExtremeHookDrift(hqRoot, status === 0);
530
645
  if (status === 0) {
646
+ // Only safe once reindex actually ran: a refused --from-hook reindex
647
+ // means a sync/rescue holds the shared operation lock and may be
648
+ // rewriting .claude/settings.json right now, and this is an unlocked
649
+ // whole-file read-modify-write.
650
+ dropProjectDefaultModeForBypassUser(hqRoot);
531
651
  await trustHqRuntimeHooks(hqRoot, undefined, {
532
652
  rootAliases: [literalHqRoot],
533
653
  // A lifecycle hook must never block the agent waiting on another HQ
@@ -20,6 +20,7 @@
20
20
  *
21
21
  * Exit codes: 0 = allow, 2 = block.
22
22
  */
23
+ import { type FlagReader } from "../flag-registry.js";
23
24
  /**
24
25
  * Worst-case inner deadline (seconds) declared in a command: a `timeout` /
25
26
  * `gtimeout` prefix (matched by executable BASENAME, so `/usr/bin/timeout`
@@ -50,9 +51,11 @@ export interface TimeoutGuardDeps {
50
51
  getEmail?: () => string | undefined;
51
52
  env?: NodeJS.ProcessEnv;
52
53
  stderr?: NodeJS.WritableStream;
54
+ /** Test seam: held registry snapshot reader. */
55
+ flagReader?: FlagReader;
53
56
  }
54
57
  /** Whether the guard is active for this identity (rollout gate). */
55
- export declare function isGatedUser(email: string | undefined): boolean;
58
+ export declare function isGatedUser(email: string | undefined, flagReader?: FlagReader): boolean;
56
59
  /**
57
60
  * Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
58
61
  * return the exit code (0 allow / 2 block). Never throws — a parse failure or a
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { loadCachedTokens } from "@indigoai-us/hq-cloud";
24
24
  import { peekIdToken } from "../../utils/id-token.js";
25
+ import { resolveFlagGate, resolveProcessFlagGate, } from "../flag-registry.js";
25
26
  // Observed Claude Code Bash-tool bounds. The tool `timeout` (ms) raises the
26
27
  // deadline up to the max; with none declared the default applies.
27
28
  const HARNESS_DEFAULT_MS = 120_000; // 2 min
@@ -165,8 +166,16 @@ function defaultEmail() {
165
166
  }
166
167
  }
167
168
  /** Whether the guard is active for this identity (rollout gate). */
168
- export function isGatedUser(email) {
169
- return typeof email === "string" && email.toLowerCase().endsWith(GATE_DOMAIN);
169
+ export function isGatedUser(email, flagReader) {
170
+ const legacyFallback = () => typeof email === "string" && email.toLowerCase().endsWith(GATE_DOMAIN);
171
+ const lookup = {
172
+ defaultEmailDomains: ["getindigo.ai"],
173
+ personEmail: email,
174
+ fallback: legacyFallback(),
175
+ };
176
+ return flagReader
177
+ ? resolveFlagGate(flagReader, "cli.foreground-timeout-guard", lookup, legacyFallback)
178
+ : resolveProcessFlagGate("cli.foreground-timeout-guard", lookup, legacyFallback);
170
179
  }
171
180
  /**
172
181
  * Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
@@ -194,7 +203,7 @@ export function timeoutGuardCommand(stdin, deps = {}) {
194
203
  return 0;
195
204
  // Rollout gate: only act for @getindigo.ai identities.
196
205
  const getEmail = deps.getEmail ?? defaultEmail;
197
- if (!isGatedUser(getEmail()))
206
+ if (!isGatedUser(getEmail(), deps.flagReader))
198
207
  return 0;
199
208
  const runInBackground = ti.run_in_background === true;
200
209
  const toolTimeoutMs = typeof ti.timeout === "number" ? ti.timeout : undefined;
@@ -0,0 +1,3 @@
1
+ /** One unref'd registry refresh for the short-lived Node CLI process. */
2
+ export {};
3
+ //# sourceMappingURL=flag-registry-worker.d.ts.map
@@ -0,0 +1,26 @@
1
+ /** One unref'd registry refresh for the short-lived Node CLI process. */
2
+ import { parentPort, workerData } from "node:worker_threads";
3
+ import { loadCachedTokens } from "@indigoai-us/hq-cloud";
4
+ import { createFlagClient } from "@indigoai-us/hq-flags-client";
5
+ const endpoint = workerData.endpoint;
6
+ async function refresh() {
7
+ if (!parentPort || typeof endpoint !== "string" || endpoint.trim() === "")
8
+ return;
9
+ try {
10
+ const client = createFlagClient({
11
+ endpoint,
12
+ getToken: () => loadCachedTokens()?.idToken ?? "",
13
+ refreshIntervalMs: 0,
14
+ onError: () => { },
15
+ });
16
+ await client.ready();
17
+ const snapshot = client.snapshot();
18
+ if (snapshot)
19
+ parentPort.postMessage(snapshot);
20
+ }
21
+ catch {
22
+ // The parent is deliberately fail-open and has the legacy fallback.
23
+ }
24
+ }
25
+ void refresh();
26
+ //# sourceMappingURL=flag-registry-worker.js.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Process-wide, fail-open bridge to the HQ flag registry.
3
+ *
4
+ * Command gates read an in-memory snapshot synchronously. The first refresh is
5
+ * isolated in an unref'd worker because an unawaited Node fetch can otherwise
6
+ * keep a short-lived, offline CLI alive until the client's network deadline.
7
+ */
8
+ import { type WorkerOptions } from "node:worker_threads";
9
+ import { type FlagClient, type FlagLookupOptions } from "@indigoai-us/hq-flags-client";
10
+ /** Dedicated endpoint configuration; this is intentionally not HQ_PRO_API_URL. */
11
+ export declare const FLAG_REGISTRY_ENDPOINT_ENV = "HQ_FLAGS_API_URL";
12
+ /** The synchronous capability that command gate call sites need. */
13
+ export type FlagReader = Pick<FlagClient, "isEnabled">;
14
+ interface WorkerLike {
15
+ on(event: "message", listener: (value: unknown) => void): unknown;
16
+ on(event: "error", listener: () => void): unknown;
17
+ unref(): void;
18
+ }
19
+ export interface FlagRegistryDependencies {
20
+ endpoint?: string;
21
+ createWorker?: (filename: URL, options: WorkerOptions) => WorkerLike;
22
+ }
23
+ /**
24
+ * Begin one registry refresh without awaiting it or retaining the CLI process.
25
+ *
26
+ * The worker owns cached-token I/O, token use, and the potentially slow fetch.
27
+ * Its response is accepted only after the client has validated it as a flag
28
+ * snapshot. Consequently, every command path is an immediate in-memory read:
29
+ * a missing, unauthenticated, slow, or offline worker leaves `heldSnapshot`
30
+ * null and the caller's exact legacy fallback decides the gate.
31
+ */
32
+ export declare function kickFlagRegistryReadiness(dependencies?: FlagRegistryDependencies): FlagReader | null;
33
+ /**
34
+ * Evaluate a registry-backed gate without changing its outage default.
35
+ * `isEnabled` itself is synchronous and snapshot-only; any reader failure
36
+ * falls through to the exact legacy predicate owned by the caller.
37
+ */
38
+ export declare function resolveFlagGate(reader: FlagReader | null | undefined, flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean): boolean;
39
+ /** Resolve a process gate against the shared snapshot reader, when configured. */
40
+ export declare function resolveProcessFlagGate(flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean): boolean;
41
+ /** Test-only reset for process-global readiness state. */
42
+ export declare function _resetFlagRegistryForTests(): void;
43
+ export {};
44
+ //# sourceMappingURL=flag-registry.d.ts.map