@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 +7 -0
- package/dist/commands/files.js +4 -1
- package/dist/commands/install-global.d.ts +2 -0
- package/dist/commands/install-global.js +38 -4
- package/dist/lib/install/provider-wrappers.d.ts +9 -0
- package/dist/lib/install/provider-wrappers.js +190 -0
- package/dist/lib/lanes/provider-rejection.d.ts +5 -5
- package/dist/lib/lanes/provider-rejection.js +15 -10
- package/dist/lib/lanes/spawn.js +9 -4
- package/dist/lib/lanes/watcher.d.ts +6 -2
- package/dist/lib/lanes/watcher.js +37 -17
- package/dist/utils/self-update.js +8 -1
- package/dist/utils/version-gate.d.ts +7 -0
- package/dist/utils/version-gate.js +37 -0
- package/package.json +1 -1
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`.
|
package/dist/commands/files.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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:
|
|
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:
|
|
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:
|
|
239
|
+
retryable: retryableProviderFailure(status, message),
|
|
235
240
|
event: "grok.error",
|
|
236
241
|
};
|
|
237
242
|
}
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -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
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
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).
|
|
107
|
-
*
|
|
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
|
|
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
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* rejection even when its text sounds like a quota message (a 402
|
|
136
|
-
* usage credits" is a
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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 {
|
|
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
|
*
|