@indigoai-us/hq-cli 5.345.46 → 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,13 @@
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
+
5
12
  ## [5.345.46] — 2026-10-08
6
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`.
7
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`.
@@ -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"));
@@ -22,6 +22,8 @@ export interface GlobalInstallOptions {
22
22
  via?: 'direct' | 'plugin';
23
23
  /** Local Claude Code marketplace directory (or HQ_CLAUDE_PLUGIN_MARKETPLACE). */
24
24
  marketplaceDir?: string;
25
+ /** Install the provider PATH wrappers after the hq-anywhere flag check. */
26
+ installProviderWrappers?: boolean;
25
27
  /** GitHub repository for the published HQ plugin marketplace. */
26
28
  marketplaceRepository?: string;
27
29
  /** Test seam for the Claude Code CLI; production uses `claude`. */
@@ -27,6 +27,7 @@ import { trustCodexUserHooks } from '../utils/hook-trust.js';
27
27
  import { DAEMON_LAUNCHD_LABEL, DAEMON_SYSTEMD_UNIT, DAEMON_WINDOWS_TASK, SYSTEM_UNIT_DIR } from '../lib/daemon/install.js';
28
28
  import { readDaemonConfig } from '../lib/daemon/config.js';
29
29
  import { daemonPaths } from '../lib/daemon/paths.js';
30
+ import { assertProviderWrappersInstallable, installProviderWrappers, providerWrapperInstallPaths, removeProviderWrappers } from '../lib/install/provider-wrappers.js';
30
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';
31
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';
32
33
  /** True when an hq daemon unit (launchd plist or systemd unit) exists. */
@@ -348,7 +349,14 @@ export function installGlobalClaude(opts) {
348
349
  const env = opts.env ?? process.env;
349
350
  const log = opts.log ?? ((l) => console.log(l));
350
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));
351
357
  assertHqRoot(hqRoot);
358
+ if (providerWrappersEnabled)
359
+ assertProviderWrappersInstallable(providerBin);
352
360
  const t = claudeTargets(home);
353
361
  const prior = readState(t);
354
362
  const pluginSource = opts.via === 'plugin' ? resolveClaudePluginSource(opts.marketplaceDir, env, opts.marketplaceRepository) : undefined;
@@ -429,6 +437,8 @@ export function installGlobalClaude(opts) {
429
437
  if (pluginSource)
430
438
  touched.push(t.settingsJson);
431
439
  touched.push(regFile, t.stateFile);
440
+ if (providerWrappersEnabled)
441
+ touched.push(...providerWrapperInstallPaths(home));
432
442
  if (opts.dryRun) {
433
443
  log(`hq install --global --runtime claude --dry-run (HQ root ${hqRoot})`);
434
444
  log('Would write:');
@@ -560,6 +570,8 @@ export function installGlobalClaude(opts) {
560
570
  seeded: [...new Set([...(prior?.seeded ?? []), ...ownedSeeded])].sort(),
561
571
  };
562
572
  writeFileAtomic(t.stateFile, `${JSON.stringify(state, null, 2)}\n`);
573
+ if (providerWrappersEnabled)
574
+ installProviderWrappers(providerBin, home);
563
575
  log(`HQ installed for Claude Code from ${hqRoot}.`);
564
576
  log(` skills linked: ${skills.link.length} new, ${skills.already.length} already present, ${skills.skipped.length} skipped`);
565
577
  reportSkippedSkills(skills, log, false);
@@ -634,6 +646,9 @@ export function uninstallGlobalClaude(opts = {}) {
634
646
  }
635
647
  const pointer = readOrNull(t.hqPointer);
636
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'));
637
652
  const pointerOwned = !codexStillInstalled && pointer !== null && pointer.trim() === hqRoot;
638
653
  if (pointerOwned)
639
654
  touched.push(t.hqPointer);
@@ -656,6 +671,8 @@ export function uninstallGlobalClaude(opts = {}) {
656
671
  if (pluginSource && state?.plugin?.marketplaceAdded) {
657
672
  runClaude(['marketplace', 'remove', '--scope', 'user', pluginSource.marketplaceName], home, env);
658
673
  }
674
+ if (!codexStillInstalled)
675
+ removeProviderWrappers(providerBin);
659
676
  unregisterClaudeServer({ name: HQ_MCP_SERVER_NAME, pack: HQ_ANYWHERE_PACK, env: { home } });
660
677
  if (fs.existsSync(t.settingsJson)) {
661
678
  writeConfigAtomic({
@@ -739,7 +756,14 @@ export function installGlobalCodex(opts) {
739
756
  const env = opts.env ?? process.env;
740
757
  const log = opts.log ?? ((l) => console.log(l));
741
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));
742
764
  assertCodexHqRoot(hqRoot);
765
+ if (providerWrappersEnabled)
766
+ assertProviderWrappersInstallable(providerBin);
743
767
  if (!isCodexInstalled({ home })) {
744
768
  throw Object.assign(new Error(`Codex is not installed (${path.join(home, '.codex')} is missing).`), {
745
769
  expected: true,
@@ -793,6 +817,8 @@ export function installGlobalCodex(opts) {
793
817
  if (tomlNext !== tomlDoc || legacyPackServerPresent)
794
818
  touched.push(t.configToml);
795
819
  touched.push(regFile, t.stateFile);
820
+ if (providerWrappersEnabled)
821
+ touched.push(...providerWrapperInstallPaths(home));
796
822
  if (opts.dryRun) {
797
823
  log(`hq install --global --runtime codex --dry-run (HQ root ${hqRoot})`);
798
824
  log('Would write:');
@@ -875,6 +901,8 @@ export function installGlobalCodex(opts) {
875
901
  seeded: [...new Set([...(prior?.seeded ?? []), ...ownedSeeded])].sort(),
876
902
  };
877
903
  writeFileAtomic(t.stateFile, `${JSON.stringify(state, null, 2)}\n`);
904
+ if (providerWrappersEnabled)
905
+ installProviderWrappers(providerBin, home);
878
906
  log(`HQ installed for Codex from ${hqRoot}.`);
879
907
  log(` skills linked: ${skills.link.length} new, ${skills.already.length} already present, ${skills.skipped.length} skipped`);
880
908
  reportSkippedSkills(skills, log, false);
@@ -895,8 +923,11 @@ export function uninstallGlobalCodex(opts = {}) {
895
923
  throw new Error('No HQ global install found (no install state and no ~/.hq/root).');
896
924
  const regFile = opts.registryFile ?? registryFile(registryDir(home, env));
897
925
  const claudeStillInstalled = fs.existsSync(claudeTargets(home).stateFile);
926
+ const providerBin = path.join(home, '.hq', 'bin');
898
927
  const packStillInstalled = fs.existsSync(path.join(hqRoot, 'core', 'packages', 'hq-anywhere', 'package.yaml'));
899
928
  const touched = [];
929
+ if (!claudeStillInstalled)
930
+ touched.push(path.join(providerBin, 'claude'), path.join(providerBin, 'codex'));
900
931
  const tomlDoc = readConfigDoc(t.configToml, codexConfigFormat).doc;
901
932
  const servers = tomlDoc.value.mcp_servers;
902
933
  if (!packStillInstalled && servers?.[HQ_MCP_SERVER_NAME]?._hqPack === HQ_ANYWHERE_PACK)
@@ -954,6 +985,8 @@ export function uninstallGlobalCodex(opts = {}) {
954
985
  }
955
986
  if (!packStillInstalled)
956
987
  unregisterCodexServer({ name: HQ_MCP_SERVER_NAME, pack: HQ_ANYWHERE_PACK, env: { home } });
988
+ if (!claudeStillInstalled)
989
+ removeProviderWrappers(providerBin);
957
990
  // Removal re-serializes the TOML (dropping comments). When what is left equals
958
991
  // the pre-install document, put the original bytes back.
959
992
  const tomlOriginal = state?.configToml.original ?? null;
@@ -1059,11 +1092,11 @@ export function resolveInstallRoot(explicit, options = {}) {
1059
1092
  export async function runInstallGlobal(opts) {
1060
1093
  const runtime = assertRuntime(opts.runtime);
1061
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
+ });
1062
1099
  if (runtime === 'chatgpt' || runtime === 'grok' || opts.dryRun) {
1063
- const enabled = await anywhereRuntimeAllowed(env, {
1064
- ...(opts.personSettingReader ? { readSetting: opts.personSettingReader } : {}),
1065
- onError: (name) => (opts.log ?? console.log)(`HQ Anywhere person setting unavailable (${name}); access remains off.`),
1066
- });
1067
1100
  if (!enabled)
1068
1101
  throw new AnywhereRuntimeDisabledError();
1069
1102
  if (runtime === 'chatgpt' || runtime === 'grok') {
@@ -1083,6 +1116,7 @@ export async function runInstallGlobal(opts) {
1083
1116
  via: opts.via,
1084
1117
  marketplaceDir: opts.marketplaceDir,
1085
1118
  marketplaceRepository: opts.marketplaceRepository,
1119
+ installProviderWrappers: enabled,
1086
1120
  env,
1087
1121
  log: opts.log,
1088
1122
  });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Throw if either wrapper target holds a non-HQ command. Callers run this before
3
+ * any install mutation so a collision cannot leave HQ half-installed.
4
+ */
5
+ export declare function assertProviderWrappersInstallable(binDir: string): void;
6
+ export declare function providerWrapperInstallPaths(home: string): string[];
7
+ export declare function installProviderWrappers(binDir: string, home?: string): string[];
8
+ export declare function removeProviderWrappers(binDir: string): string[];
9
+ //# sourceMappingURL=provider-wrappers.d.ts.map
@@ -0,0 +1,190 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ const marker = '# hq-anywhere provider sign-in wrapper';
5
+ const PROVIDER_PATH_RC_FILES = ['.bashrc', '.bash_profile', '.zshrc', '.zprofile', '.profile'];
6
+ const wrapperHeader = (provider) => `#!/bin/sh\n${marker}: ${provider}\n`;
7
+ /**
8
+ * Throw if either wrapper target holds a non-HQ command. Callers run this before
9
+ * any install mutation so a collision cannot leave HQ half-installed.
10
+ */
11
+ export function assertProviderWrappersInstallable(binDir) {
12
+ for (const provider of ['claude', 'codex']) {
13
+ const file = path.join(binDir, provider);
14
+ let current;
15
+ try {
16
+ current = fs.readFileSync(file, 'utf8');
17
+ }
18
+ catch (e) {
19
+ if (e.code === 'ENOENT')
20
+ continue;
21
+ throw e;
22
+ }
23
+ if (!current.startsWith(wrapperHeader(provider))) {
24
+ throw new Error(`Refusing to replace an existing ${provider} command at ${file}.`);
25
+ }
26
+ }
27
+ }
28
+ export function providerWrapperInstallPaths(home) {
29
+ const binDir = path.join(home, '.hq', 'bin');
30
+ const paths = ['claude', 'codex'].map((provider) => path.join(binDir, provider));
31
+ const pathSetupMarker = path.join(home, '.hq', 'provider-path-installed');
32
+ if (!fs.existsSync(pathSetupMarker)) {
33
+ paths.push(pathSetupMarker, ...PROVIDER_PATH_RC_FILES.map((rc) => path.join(home, rc)));
34
+ }
35
+ return paths;
36
+ }
37
+ function wrapperSource(provider) {
38
+ const pretty = provider === 'claude' ? 'Claude Code' : 'Codex';
39
+ const login = provider === 'claude' ? 'claude /login' : 'codex login';
40
+ const statusArgs = provider === 'claude' ? 'auth status' : 'login status';
41
+ const authFailure = provider === 'codex'
42
+ ? '401|unauthorized|not logged in|signed out|not authenticated|authentication required|unauthenticated|missing.*(bearer|credential|auth)|websocket.*auth'
43
+ : '401|unauthorized|not logged in|signed out|not authenticated|authentication required|unauthenticated|missing.*(bearer|credential|auth)';
44
+ const runAuthFailure = provider === 'codex'
45
+ ? 'HTTP error: *401|status 401|401 Unauthorized|websocket.*auth|not logged in|signed out|not authenticated|authentication required|unauthenticated'
46
+ : 'HTTP error: *401|status 401|401 Unauthorized|not logged in|signed out|not authenticated|authentication required|unauthenticated';
47
+ return [
48
+ '#!/bin/sh',
49
+ `${marker}: ${provider}`,
50
+ 'set -u',
51
+ 'umask 077',
52
+ `name=${provider}`,
53
+ `pretty='${pretty}'`,
54
+ `login='${login}'`,
55
+ `provider='${provider}'`,
56
+ '',
57
+ '# Skip this managed wrapper and locate the provider executable later in PATH.',
58
+ 'self=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)/$(basename -- "$0")',
59
+ 'real=',
60
+ 'old_ifs=$IFS',
61
+ 'IFS=:',
62
+ 'for dir in $PATH; do',
63
+ ' [ -n "$dir" ] || dir=.',
64
+ ' candidate=$(CDPATH= cd -- "$dir" 2>/dev/null && pwd -P)/$name',
65
+ ' if [ -x "$candidate" ] && [ "$candidate" != "$self" ]; then real=$candidate; break; fi',
66
+ 'done',
67
+ 'IFS=$old_ifs',
68
+ 'if [ -z "$real" ]; then printf \'%s CLI was not found on PATH.\\n\' "$pretty" >&2; exit 127; fi',
69
+ '',
70
+ '# The flag override is part of the existing hq-anywhere-runtime flag contract.',
71
+ 'case "${HQ_FLAG_HQ_ANYWHERE_RUNTIME:-}" in false|0) exec "$real" "$@" ;; esac',
72
+ '',
73
+ '# Login, auth, and informational commands must remain reachable while signed out.',
74
+ 'case "${1:-}" in',
75
+ ' --version|-v|--help|-h) exec "$real" "$@" ;;',
76
+ ` ${provider === 'claude' ? '/login|auth' : 'login'}) exec "$real" "$@" ;;`,
77
+ 'esac',
78
+ '',
79
+ 'log_dir="$HOME/.hq/logs"',
80
+ 'mkdir -p "$log_dir" 2>/dev/null || :',
81
+ 'log_file="$log_dir/provider-auth.log"',
82
+ 'tmp=$(mktemp -d "${TMPDIR:-/tmp}/hq-provider.XXXXXX") || exit 1',
83
+ 'trap \'rm -rf "$tmp"\' EXIT HUP INT TERM',
84
+ `if command -v timeout >/dev/null 2>&1; then timeout 5 "$real" ${statusArgs} >"$tmp/status" 2>&1; auth_status=$?; elif command -v perl >/dev/null 2>&1; then perl -e 'alarm 5; exec @ARGV' "$real" ${statusArgs} >"$tmp/status" 2>&1; auth_status=$?; else auth_status=124; fi`,
85
+ 'case "$auth_status" in 124|142) : >"$tmp/status-timeout" ;; esac',
86
+ 'if [ "$auth_status" -ne 0 ]; then cat "$tmp/status" >>"$log_file" 2>/dev/null || :; fi',
87
+ `if [ ! -e "$tmp/status-timeout" ] && grep -Eiq '${authFailure}' "$tmp/status"; then`,
88
+ ' cat "$tmp/status" >>"$log_file" 2>/dev/null || :',
89
+ ' printf \'%s\\n\' "[$(date -u +%FT%TZ)] $pretty sign-in check reported unauthenticated" >>"$log_file" 2>/dev/null || :',
90
+ ' printf \'%s is not signed in. Sign in to use HQ Anywhere:\\n %s\\n\' "$pretty" "$login" >&2',
91
+ ' exit 1',
92
+ 'fi',
93
+ 'if [ -t 1 ] && { [ "$provider" = claude ] && [ "${1:-}" != -p ] && [ "${1:-}" != --print ] || [ "$provider" = codex ] && [ "${1:-}" != exec ]; }; then exec "$real" "$@"; fi',
94
+ '',
95
+ '# Leave provider stdout attached to the caller so streaming and TTY state survive.',
96
+ 'mkfifo "$tmp/stderr.pipe"',
97
+ 'filter_stderr() {',
98
+ ' trap - EXIT HUP INT TERM',
99
+ ' while :; do',
100
+ ' line=',
101
+ ' IFS= read -r line',
102
+ ' read_status=$?',
103
+ ' [ -n "$line" ] || [ "$read_status" -eq 0 ] || break',
104
+ ' if printf \'%s\\n\' "$line" | grep -Eiq "' + runAuthFailure + '"; then',
105
+ ' printf \'%s\\n\' "$line" >>"$tmp/auth-found"',
106
+ ' else',
107
+ ' printf \'%s\' "$line" >&2',
108
+ ' [ "$read_status" -eq 0 ] && printf \'\\n\' >&2',
109
+ ' fi',
110
+ ' [ "$read_status" -eq 0 ] || break',
111
+ ' done <"$tmp/stderr.pipe"',
112
+ '}',
113
+ 'filter_stderr &',
114
+ 'filter_pid=$!',
115
+ '"$real" "$@" 2>"$tmp/stderr.pipe"',
116
+ 'status=$?',
117
+ 'wait "$filter_pid"',
118
+ `if [ "$status" -ne 0 ] && [ -f "$tmp/auth-found" ]; then`,
119
+ ' # Only the matched authentication lines are logged; the run\'s other output can',
120
+ ' # carry prompts, source or downstream secrets and never reaches this file.',
121
+ ' { printf \'\\n[hq-anywhere] %s returned an authentication failure.\\n\' "$pretty"; cat "$tmp/auth-found"; } >>"$log_file" 2>/dev/null || :',
122
+ ` printf \'%s could not authenticate this run. Sign in to use HQ Anywhere:\\n %s\\n\' "$pretty" "$login" >&2`,
123
+ ' exit "$status"',
124
+ 'fi',
125
+ 'exit "$status"',
126
+ '',
127
+ ].join('\n');
128
+ }
129
+ export function installProviderWrappers(binDir, home = path.dirname(path.dirname(binDir))) {
130
+ fs.mkdirSync(binDir, { recursive: true, mode: 0o755 });
131
+ const files = ['claude', 'codex'].map((provider) => path.join(binDir, provider));
132
+ // Both targets are checked before either is written, so a collision on codex
133
+ // cannot leave a freshly written claude wrapper behind.
134
+ assertProviderWrappersInstallable(binDir);
135
+ for (const provider of ['claude', 'codex']) {
136
+ const file = path.join(binDir, provider);
137
+ fs.writeFileSync(file, wrapperSource(provider), { mode: 0o755 });
138
+ fs.chmodSync(file, 0o755);
139
+ }
140
+ // Startup files are append-only. The marker avoids reading any shell startup file.
141
+ const pathSetupMarker = path.join(path.dirname(binDir), 'provider-path-installed');
142
+ if (!fs.existsSync(pathSetupMarker)) {
143
+ const pathLine = '\nexport PATH="$HOME/.hq/bin:$PATH" # hq-anywhere provider commands\n';
144
+ // Only startup files that already exist are edited. Creating .bash_profile or
145
+ // .zprofile would make the login shell select it and stop reading the user's
146
+ // existing .profile, silently dropping their environment setup.
147
+ const existing = PROVIDER_PATH_RC_FILES.filter((rc) => fs.existsSync(path.join(home, rc)));
148
+ const createdRcFiles = [];
149
+ if (existing.length === 0) {
150
+ // .profile shadows nothing: every POSIX login shell reads it, and no other
151
+ // startup file is present for it to take precedence over.
152
+ createdRcFiles.push('.profile');
153
+ }
154
+ for (const rc of [...existing, ...createdRcFiles]) {
155
+ fs.appendFileSync(path.join(home, rc), pathLine, { mode: 0o600 });
156
+ }
157
+ fs.writeFileSync(pathSetupMarker, `${createdRcFiles.join('\n')}\n`, { mode: 0o600 });
158
+ }
159
+ return files;
160
+ }
161
+ export function removeProviderWrappers(binDir) {
162
+ const removed = [];
163
+ for (const provider of ['claude', 'codex']) {
164
+ const file = path.join(binDir, provider);
165
+ if (!fs.existsSync(file))
166
+ continue;
167
+ const current = fs.readFileSync(file, 'utf8');
168
+ if (!current.startsWith(wrapperHeader(provider)))
169
+ continue;
170
+ fs.unlinkSync(file);
171
+ removed.push(file);
172
+ }
173
+ const home = path.dirname(path.dirname(binDir));
174
+ const pathSetupMarker = path.join(home, '.hq', 'provider-path-installed');
175
+ if (fs.existsSync(pathSetupMarker)) {
176
+ const createdRcFiles = new Set(fs.readFileSync(pathSetupMarker, 'utf8').split('\n').filter(Boolean));
177
+ const deletePathSetup = 's/\\nexport PATH="\\$HOME\\/\\.hq\\/bin:\\$PATH" # hq-anywhere provider commands\\n//';
178
+ for (const rc of PROVIDER_PATH_RC_FILES) {
179
+ const file = path.join(home, rc);
180
+ if (fs.existsSync(file)) {
181
+ execFileSync('perl', ['-0pi', '-e', deletePathSetup, file]);
182
+ if (createdRcFiles.has(rc))
183
+ fs.unlinkSync(file);
184
+ }
185
+ }
186
+ fs.unlinkSync(pathSetupMarker);
187
+ }
188
+ return removed;
189
+ }
190
+ //# sourceMappingURL=provider-wrappers.js.map
@@ -6,13 +6,13 @@
6
6
  * output and no envelope. Without this module the watcher classifies that
7
7
  * `interrupted` with `exited_with_partial_output`, and the operator has to
8
8
  * read the raw log to learn why. Here the failure is named: the lane is
9
- * `failed` with `terminal_reason: provider_rejected` and the provider's own
10
- * message on `terminal_details`.
9
+ * terminalized with a named provider reason and the provider's own message on
10
+ * `terminal_details`; transient failures remain resumable.
11
11
  *
12
12
  * A 429 is parsed here like any other status so that a codex or grok 429,
13
- * which the claude-shaped quota reader cannot see, still reaches the quota
14
- * path: the watcher routes a `status === QUOTA_STATUS` failure to quota.ts
15
- * (reset time, account hold) and everything else to `provider_rejected`.
13
+ * which the claude-shaped quota reader cannot see, still reaches the watcher.
14
+ * Quota-specific messages become account holds; generic rate-limit responses
15
+ * remain retryable provider rejections.
16
16
  *
17
17
  * Only the fields named below are read out of the payload. The prompt, the
18
18
  * response body and everything else stay on disk. Nothing here logs.
@@ -6,13 +6,13 @@
6
6
  * output and no envelope. Without this module the watcher classifies that
7
7
  * `interrupted` with `exited_with_partial_output`, and the operator has to
8
8
  * read the raw log to learn why. Here the failure is named: the lane is
9
- * `failed` with `terminal_reason: provider_rejected` and the provider's own
10
- * message on `terminal_details`.
9
+ * terminalized with a named provider reason and the provider's own message on
10
+ * `terminal_details`; transient failures remain resumable.
11
11
  *
12
12
  * A 429 is parsed here like any other status so that a codex or grok 429,
13
- * which the claude-shaped quota reader cannot see, still reaches the quota
14
- * path: the watcher routes a `status === QUOTA_STATUS` failure to quota.ts
15
- * (reset time, account hold) and everything else to `provider_rejected`.
13
+ * which the claude-shaped quota reader cannot see, still reaches the watcher.
14
+ * Quota-specific messages become account holds; generic rate-limit responses
15
+ * remain retryable provider rejections.
16
16
  *
17
17
  * Only the fields named below are read out of the payload. The prompt, the
18
18
  * response body and everything else stay on disk. Nothing here logs.
@@ -114,10 +114,14 @@ function capMessage(message) {
114
114
  export function retryableFromStatus(status) {
115
115
  if (status === null)
116
116
  return false;
117
- if (status === 408 || status === 425)
117
+ if (status === 408 || status === 425 || status === 429)
118
118
  return true;
119
119
  return status >= 500 && status <= 599;
120
120
  }
121
+ function retryableProviderFailure(status, message) {
122
+ return retryableFromStatus(status) ||
123
+ (status === null && /\b(?:rate limit|too many requests)\b/i.test(message));
124
+ }
121
125
  /**
122
126
  * Codex `exec --json`: `{"type":"turn.failed","error":{"message":"<text>"}}`.
123
127
  * Measured 2026-09-22 (lane 01M34HCCP1ZDN3H68ESCRZE7RZ): the message is
@@ -151,7 +155,7 @@ export function parseCodexTurnFailed(text) {
151
155
  message: capMessage(message),
152
156
  status,
153
157
  error_type: errorType,
154
- retryable: retryableFromStatus(status),
158
+ retryable: retryableProviderFailure(status, message),
155
159
  event: "codex.turn.failed",
156
160
  };
157
161
  });
@@ -175,15 +179,16 @@ export function parseClaudeResultRejection(text) {
175
179
  if (rec.is_error !== true && !loginFailure)
176
180
  return NOT_REJECTED;
177
181
  const status = statusOf(rec.api_error_status);
178
- if (status === null && !loginFailure)
182
+ if (status === null && !loginFailure && !retryableProviderFailure(status, message)) {
179
183
  return NOT_REJECTED;
184
+ }
180
185
  return {
181
186
  reason: PROVIDER_REJECTED_REASON,
182
187
  provider: "claude",
183
188
  message: capMessage(message),
184
189
  status,
185
190
  error_type: typeof rec.subtype === "string" ? rec.subtype : null,
186
- retryable: retryableFromStatus(status),
191
+ retryable: retryableProviderFailure(status, message),
187
192
  event: loginFailure && rec.is_error !== true
188
193
  ? "claude.result.login_failure"
189
194
  : "claude.result.is_error",
@@ -231,7 +236,7 @@ function grokRejectionFrom(rec) {
231
236
  message: capMessage(message),
232
237
  status,
233
238
  error_type: null,
234
- retryable: retryableFromStatus(status),
239
+ retryable: retryableProviderFailure(status, message),
235
240
  event: "grok.error",
236
241
  };
237
242
  }
@@ -1621,11 +1621,16 @@ export function markSpawnFailure(hqRoot, laneId, reasonRaw, details, options = {
1621
1621
  : []),
1622
1622
  ].filter((value) => typeof value === "string" && Number.isFinite(Date.parse(value)));
1623
1623
  const quotaResetAt = resetCandidates.sort((a, b) => Date.parse(a) - Date.parse(b))[0];
1624
- const resumableQuotaHold = current.keep_alive === true &&
1625
- (current.pending_resume !== undefined || options.resumeTrigger !== undefined) &&
1626
- (reason === "account_quota_exhausted" || reason === "account_model_unavailable") &&
1624
+ // Initial queued lanes can be retried by reconcile without a session;
1625
+ // ordinary resumes keep their existing failed-state behavior.
1626
+ const initialQueuedSpawn = current.state === "queued" &&
1627
+ current.pending_resume === undefined && options.resumeTrigger === undefined;
1628
+ const keepAliveResume = current.keep_alive === true &&
1629
+ (current.pending_resume !== undefined || options.resumeTrigger !== undefined);
1630
+ const resumableQuotaHold = (reason === "account_quota_exhausted" || reason === "account_model_unavailable") &&
1627
1631
  typeof quotaResetAt === "string" &&
1628
- Number.isFinite(Date.parse(quotaResetAt));
1632
+ Number.isFinite(Date.parse(quotaResetAt)) &&
1633
+ (initialQueuedSpawn || keepAliveResume);
1629
1634
  if (current.state === "failed" && existing === reason && !resumableQuotaHold)
1630
1635
  return current;
1631
1636
  const operatorQuotaHold = resumableQuotaHold && options.resumeTrigger !== undefined;
@@ -76,6 +76,7 @@ export interface WatcherTickInput {
76
76
  errors: string[];
77
77
  } | null;
78
78
  providerRejection?: ProviderRejection | null;
79
+ hasProviderSession?: boolean;
79
80
  }
80
81
  export interface WatcherTickResult {
81
82
  emit?: WatcherSignal;
@@ -103,10 +104,13 @@ export declare function classifyLaneExit(input: {
103
104
  /**
104
105
  * Set when the worker's output stream carries a provider-level turn
105
106
  * failure (codex `turn.failed`, a Claude error result with an HTTP status,
106
- * a grok error object). Failed, never interrupted: the provider refused
107
- * the turn, so there is nothing to resume and no envelope to repair.
107
+ * a grok error object). A retryable refusal interrupts only when a provider
108
+ * session exists to resume; without one, the lane fails with the named
109
+ * provider reason. Neither path attempts envelope repair.
108
110
  */
109
111
  providerRejection?: ProviderRejection | null;
112
+ /** True only when the lane has a current or prior provider session id. */
113
+ hasProviderSession?: boolean;
110
114
  }): TerminalClassification;
111
115
  export declare function routeProviderFailure(failure: ProviderRejection | null, readQuota: () => QuotaSignal | null, model: string | undefined, now: Date): {
112
116
  quota: QuotaSignal | null;
@@ -106,11 +106,19 @@ export function classifyLaneExit(input) {
106
106
  throw new LanesError("unknown_envelope_decision", `Unknown envelope decision ${JSON.stringify(input.envelope.decision)}. Accepted: done, ask, blocked`, { accepted: ["done", "ask", "blocked"], value: input.envelope.decision });
107
107
  }
108
108
  if (input.providerRejection) {
109
+ const rejection = input.providerRejection;
110
+ const reason = isProviderLoginFailure(rejection)
111
+ ? "account_not_logged_in"
112
+ : rejection.status === 402
113
+ ? "provider_billing_required"
114
+ : rejection.status === 401 || rejection.status === 403
115
+ ? "provider_auth_failed"
116
+ : rejection.status === 429 || /\b(?:rate limit|too many requests)\b/i.test(rejection.message)
117
+ ? "provider_rate_limited"
118
+ : PROVIDER_REJECTED_REASON;
109
119
  return {
110
- state: "failed",
111
- reason: isProviderLoginFailure(input.providerRejection)
112
- ? "account_not_logged_in"
113
- : PROVIDER_REJECTED_REASON,
120
+ state: rejection.retryable && input.hasProviderSession === true ? "interrupted" : "failed",
121
+ reason,
114
122
  };
115
123
  }
116
124
  if (input.invalidEnvelope) {
@@ -129,15 +137,14 @@ export function classifyLaneExit(input) {
129
137
  }
130
138
  /**
131
139
  * One decision for an exited worker's output. An explicit provider status
132
- * wins over the quota text heuristics: a 429 from any provider (including
133
- * codex `turn.failed` and grok error objects, which the claude-shaped quota
134
- * reader never sees) becomes a quota signal; any other explicit status is a
135
- * rejection even when its text sounds like a quota message (a 402 "requires
136
- * usage credits" is a rejection, not a hold). A failure with no status at
137
- * all is matched against the quota text heuristics first: a status-less
138
- * "usage limit" / "rate limit" turn.failed still creates the account hold;
139
- * any other status-less message is a rejection. Only when the stream
140
- * carries no failure object does the quota reader's own read apply.
140
+ * wins over the quota text heuristics: a 429 with quota-specific text or a
141
+ * usage-limit error type becomes an account hold; a generic rate-limit
142
+ * response stays a retryable provider rejection. Any other explicit status
143
+ * is a rejection even when its text sounds like a quota message (a 402
144
+ * "requires usage credits" is a billing refusal, not a hold). A status-less
145
+ * usage-limit message can create an account hold, while a status-less
146
+ * rate-limit message remains a retryable provider rejection. Only when the
147
+ * stream carries no failure object does the quota reader's own read apply.
141
148
  */
142
149
  function validFailureOutputTime(value, fallback) {
143
150
  const parsed = new Date(value);
@@ -146,21 +153,31 @@ function validFailureOutputTime(value, fallback) {
146
153
  : fallback;
147
154
  }
148
155
  export function routeProviderFailure(failure, readQuota, model, now) {
149
- if (failure && failure.status === QUOTA_STATUS) {
156
+ if (failure && failure.status === QUOTA_STATUS && isAccountQuotaFailure(failure)) {
150
157
  return {
151
158
  quota: quotaSignalFromFields({ api_error_status: QUOTA_STATUS, is_error: true, result: failure.message }, model, failure.output_at ? validFailureOutputTime(failure.output_at, now) : now),
152
159
  rejection: null,
153
160
  };
154
161
  }
155
- if (failure && failure.status === null) {
162
+ if (failure && failure.status === null && isAccountQuotaFailure(failure)) {
156
163
  const quota = quotaSignalFromFields({ api_error_status: undefined, is_error: true, result: failure.message }, model, failure.output_at ? validFailureOutputTime(failure.output_at, now) : now);
157
164
  if (quota)
158
165
  return { quota, rejection: null };
159
166
  }
160
- if (failure)
161
- return { quota: null, rejection: failure };
167
+ if (failure) {
168
+ return {
169
+ quota: null,
170
+ rejection: failure.status === QUOTA_STATUS
171
+ ? { ...failure, retryable: true }
172
+ : failure,
173
+ };
174
+ }
162
175
  return { quota: readQuota(), rejection: null };
163
176
  }
177
+ function isAccountQuotaFailure(failure) {
178
+ return failure.error_type === "usage_limit_reached" ||
179
+ /weekly limit|usage limit|quota|usage credits|hit your .+ limit/i.test(failure.message);
180
+ }
164
181
  export function tickWatcher(input) {
165
182
  const silenceMs = input.silenceMs ?? SILENCE_MS;
166
183
  const at = new Date(input.nowMs).toISOString();
@@ -171,6 +188,7 @@ export function tickWatcher(input) {
171
188
  quota: input.quota,
172
189
  invalidEnvelope: input.invalidEnvelope,
173
190
  providerRejection: input.providerRejection,
191
+ hasProviderSession: input.hasProviderSession,
174
192
  });
175
193
  return {
176
194
  emit: { kind: "terminal", at, classification },
@@ -1578,6 +1596,8 @@ async function runWatcherLoopOwned(hqRoot, laneId, deps) {
1578
1596
  quota,
1579
1597
  invalidEnvelope,
1580
1598
  providerRejection,
1599
+ hasProviderSession: typeof quotaLane?.provider_session_id === "string" &&
1600
+ quotaLane.provider_session_id.trim().length > 0,
1581
1601
  });
1582
1602
  if (processAlive) {
1583
1603
  maybeHeartbeatLane(hqRoot, laneId, now());
@@ -65,7 +65,7 @@ import chalk from "chalk";
65
65
  import { Sentry } from "../sentry.js";
66
66
  import { isCliTelemetryDisabled } from "./cli-telemetry.js";
67
67
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
68
- import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, canWriteNpmInstall, captureNonWritableNpmPrefixNotice, claimNonWritableNpmPrefixNotice, checkUpdateConvergence, derivePnpmHome, detachUpdateSupervisor, isBunManagedPackageDir, isLocalDependencyInstall, isPnpmManagedPackageDir, nonWritablePrefixNote, openInstallOutput, pnpmUpdateEnv, pnpmInstalledVersion, performPnpmUpdate, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, UPDATE_RECURSION_GUARD_ENV, } from "./version-gate.js";
68
+ import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, canWriteNpmInstall, captureNonWritableNpmPrefixNotice, claimNonWritableNpmPrefixNotice, checkUpdateConvergence, derivePnpmHome, detachUpdateSupervisor, isBunManagedPackageDir, isLocalDependencyInstall, isPnpmManagedPackageDir, nonWritablePrefixNote, openInstallOutput, pnpmPathConflict, pnpmUpdateEnv, pnpmInstalledVersion, performPnpmUpdate, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, UPDATE_RECURSION_GUARD_ENV, } from "./version-gate.js";
69
69
  import { canStageInstall, stagedNpmInstall } from "./staged-install.js";
70
70
  import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
71
71
  import { markLatestIneffective } from "./version-check.js";
@@ -931,6 +931,13 @@ async function attemptUpdateAndReexec(argv, flavor, known, deps, env) {
931
931
  console.error(chalk.dim(`hq-cli ${latest} is available, but this copy is a local dependency (${install.packageRoot}) — update the project that owns it.`));
932
932
  return { action: "skipped", latest };
933
933
  }
934
+ if (install.manager === "pnpm") {
935
+ const conflict = pnpmPathConflict(install);
936
+ if (conflict) {
937
+ console.error(chalk.yellow(`⚠ hq self-update refused: ${conflict}`));
938
+ return { action: "skipped", latest };
939
+ }
940
+ }
934
941
  const releaseLock = flavor.lock ? (deps.acquireLock ?? acquireUpdateLock)() : () => { };
935
942
  if (!releaseLock)
936
943
  return { action: "skipped", latest };
@@ -430,6 +430,13 @@ declare function resolveHqOnPath(): string | null;
430
430
  export declare function parseReportedCliVersion(stdout: string): string | null;
431
431
  /** `<bin> --version` output (trimmed), or null on any failure/timeout. */
432
432
  declare function probeCliVersion(bin: string): string | null;
433
+ /**
434
+ * Detect a second pnpm global install winning PATH before the updater mutates
435
+ * the running install. pnpm homes have independent global package trees and
436
+ * shims, so writing one home cannot change a different home that appears first
437
+ * on PATH.
438
+ */
439
+ export declare function pnpmPathConflict(install: RunningInstall): string | null;
433
440
  /**
434
441
  * Read-your-writes convergence check, run after an install reports success.
435
442
  *
@@ -1395,6 +1395,43 @@ function probeCliVersion(bin) {
1395
1395
  return null;
1396
1396
  }
1397
1397
  }
1398
+ /**
1399
+ * Detect a second pnpm global install winning PATH before the updater mutates
1400
+ * the running install. pnpm homes have independent global package trees and
1401
+ * shims, so writing one home cannot change a different home that appears first
1402
+ * on PATH.
1403
+ */
1404
+ export function pnpmPathConflict(install) {
1405
+ if (install.manager !== "pnpm" || !install.packageRoot)
1406
+ return null;
1407
+ const bin = resolveHqOnPath();
1408
+ if (!bin)
1409
+ return null;
1410
+ const pathPackageRoot = findPackageRootForExecutable(bin);
1411
+ if (!pathPackageRoot ||
1412
+ globalInstallManagerForPath(bin, pathPackageRoot) !== "pnpm") {
1413
+ return null;
1414
+ }
1415
+ // The fallback resolver scans every hash in a PNPM_HOME because generated
1416
+ // shims do not expose their target. Multiple installs in the SAME home can
1417
+ // therefore produce an arbitrary package root; only a different home means
1418
+ // that updating this install cannot change which global shim wins PATH.
1419
+ const runningHome = derivePnpmHome(install);
1420
+ const pathHome = derivePnpmHome({ manager: "pnpm", prefix: null, packageRoot: pathPackageRoot });
1421
+ if (!runningHome || !pathHome || samePath(runningHome, pathHome))
1422
+ return null;
1423
+ let resolvedBin = bin;
1424
+ try {
1425
+ resolvedBin = realpathSync(bin);
1426
+ }
1427
+ catch {
1428
+ // Keep the PATH entry when a platform shim cannot be canonicalized.
1429
+ }
1430
+ const version = probeCliVersion(bin) ?? "unknown";
1431
+ return (`PATH resolves ${resolvedBin} at version ${version} (manager: pnpm), ` +
1432
+ `but this process is running from a different pnpm install at ${install.packageRoot}. ` +
1433
+ "Refusing to update one pnpm home while another hq-cli install wins PATH resolution.");
1434
+ }
1398
1435
  /**
1399
1436
  * Read-your-writes convergence check, run after an install reports success.
1400
1437
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.46",
3
+ "version": "5.345.47",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {