@indigoai-us/hq-cli 5.345.45 → 5.345.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/commands/files.js +4 -1
- package/dist/commands/install-global.d.ts +5 -0
- package/dist/commands/install-global.js +62 -6
- package/dist/commands/lanes.js +40 -11
- package/dist/lib/anywhere-person-setting.d.ts +11 -0
- package/dist/lib/anywhere-person-setting.js +19 -0
- package/dist/lib/daemon/ops/mcp.d.ts +10 -1
- package/dist/lib/daemon/ops/mcp.js +17 -10
- package/dist/lib/daemon/run.js +8 -2
- package/dist/lib/doctor/checks/sync-runtime.d.ts +2 -0
- package/dist/lib/doctor/checks/sync-runtime.js +26 -12
- package/dist/lib/install/claude-targets.js +7 -2
- package/dist/lib/install/codex-targets.d.ts +6 -0
- package/dist/lib/install/codex-targets.js +25 -0
- 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/hook-trust.d.ts +7 -0
- package/dist/utils/hook-trust.js +40 -14
- 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
|
@@ -155,6 +155,31 @@ export function removeCodexHooks(doc, hqRoot) {
|
|
|
155
155
|
next.hooks = hooks;
|
|
156
156
|
return next;
|
|
157
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Codex trust keys (`<hooks.json>:<snake_event>:<group>:<hook>`) for the HQ
|
|
160
|
+
* entries in a hooks.json document. Foreign entries are left out so install
|
|
161
|
+
* never trusts a hook the user added themselves.
|
|
162
|
+
*/
|
|
163
|
+
export function codexHookTrustKeys(doc, hooksJsonPath, hqRoot) {
|
|
164
|
+
const hooks = doc.hooks;
|
|
165
|
+
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks))
|
|
166
|
+
return [];
|
|
167
|
+
const keys = [];
|
|
168
|
+
for (const [event, value] of Object.entries(hooks)) {
|
|
169
|
+
if (!Array.isArray(value))
|
|
170
|
+
continue;
|
|
171
|
+
const snake = event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
172
|
+
value.forEach((group, g) => {
|
|
173
|
+
if (!group || !Array.isArray(group.hooks))
|
|
174
|
+
return;
|
|
175
|
+
group.hooks.forEach((entry, h) => {
|
|
176
|
+
if (isAdapterEntry(entry, hqRoot))
|
|
177
|
+
keys.push(`${hooksJsonPath}:${snake}:${g}:${h}`);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return keys;
|
|
182
|
+
}
|
|
158
183
|
export function hasAllCodexHooks(doc, hqRoot, target = 'master') {
|
|
159
184
|
return addCodexHooks(doc, hqRoot, target) === doc;
|
|
160
185
|
}
|
|
@@ -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());
|
|
@@ -53,6 +53,13 @@ export interface RuntimeHookTrustResult {
|
|
|
53
53
|
export declare function createCodexAppServerClient(cwd: string, executable?: string, args?: string[]): Promise<CodexRpcClient>;
|
|
54
54
|
/** Trust only hooks declared by this HQ root's project `.codex/` layer. */
|
|
55
55
|
export declare function trustCodexProjectHooks(hqRoot: string, deps?: HookTrustDependencies): Promise<RuntimeHookTrustResult>;
|
|
56
|
+
/**
|
|
57
|
+
* Trust the user-scope hooks `hq install --global --runtime codex` registered
|
|
58
|
+
* in ~/.codex/hooks.json, named by their Codex keys (`<file>:<event>:<group>:<hook>`).
|
|
59
|
+
* Codex skips untrusted hooks, so without this HQ stays inert in every session
|
|
60
|
+
* outside the HQ folder. Other hooks in the same file are never trusted here.
|
|
61
|
+
*/
|
|
62
|
+
export declare function trustCodexUserHooks(cwd: string, keys: readonly string[], deps?: HookTrustDependencies): Promise<RuntimeHookTrustResult>;
|
|
56
63
|
/**
|
|
57
64
|
* Mark this HQ root as a trusted Claude Code workspace.
|
|
58
65
|
*
|
package/dist/utils/hook-trust.js
CHANGED
|
@@ -181,10 +181,37 @@ export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
|
|
|
181
181
|
if (!fs.existsSync(path.join(hqRoot, '.codex'))) {
|
|
182
182
|
return { runtime: 'codex', status: 'skipped', trusted: 0, reason: 'project .codex layer absent' };
|
|
183
183
|
}
|
|
184
|
+
return trustCodexHooks(hqRoot, deps, (hooks) => {
|
|
185
|
+
const projectHooks = hqProjectHooks(hqRoot, hooks);
|
|
186
|
+
return projectHooks.length === 0
|
|
187
|
+
? { skip: 'no HQ project hooks discovered' }
|
|
188
|
+
: { hooks: projectHooks };
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Trust the user-scope hooks `hq install --global --runtime codex` registered
|
|
193
|
+
* in ~/.codex/hooks.json, named by their Codex keys (`<file>:<event>:<group>:<hook>`).
|
|
194
|
+
* Codex skips untrusted hooks, so without this HQ stays inert in every session
|
|
195
|
+
* outside the HQ folder. Other hooks in the same file are never trusted here.
|
|
196
|
+
*/
|
|
197
|
+
export async function trustCodexUserHooks(cwd, keys, deps = DEFAULT_DEPS) {
|
|
198
|
+
if (keys.length === 0) {
|
|
199
|
+
return { runtime: 'codex', status: 'skipped', trusted: 0, reason: 'no HQ user hooks registered' };
|
|
200
|
+
}
|
|
201
|
+
return trustCodexHooks(cwd, deps, (hooks) => {
|
|
202
|
+
const byKey = new Map(hooks.filter((hook) => hook.source === 'user' && !hook.isManaged).map((hook) => [hook.key, hook]));
|
|
203
|
+
const missing = keys.filter((key) => !byKey.has(key));
|
|
204
|
+
if (missing.length > 0)
|
|
205
|
+
return { fail: `Codex did not discover: ${missing.join(', ')}` };
|
|
206
|
+
return { hooks: keys.map((key) => byKey.get(key)) };
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** List hooks for `cwd`, trust and enable the selected ones, then verify. */
|
|
210
|
+
async function trustCodexHooks(cwd, deps, select) {
|
|
184
211
|
let client;
|
|
185
212
|
try {
|
|
186
|
-
client = await deps.createCodexClient(
|
|
187
|
-
const discovered = hooksFromListResponse(await client.request('hooks/list', { cwds: [
|
|
213
|
+
client = await deps.createCodexClient(cwd);
|
|
214
|
+
const discovered = hooksFromListResponse(await client.request('hooks/list', { cwds: [cwd] }));
|
|
188
215
|
if (discovered.errors.length > 0) {
|
|
189
216
|
return {
|
|
190
217
|
runtime: 'codex',
|
|
@@ -193,19 +220,18 @@ export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
|
|
|
193
220
|
reason: discovered.errors.join('; '),
|
|
194
221
|
};
|
|
195
222
|
}
|
|
196
|
-
const
|
|
197
|
-
if (
|
|
198
|
-
return {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
reason: 'no HQ project hooks discovered',
|
|
203
|
-
};
|
|
223
|
+
const selection = select(discovered.hooks);
|
|
224
|
+
if ('skip' in selection) {
|
|
225
|
+
return { runtime: 'codex', status: 'skipped', trusted: 0, reason: selection.skip };
|
|
226
|
+
}
|
|
227
|
+
if ('fail' in selection) {
|
|
228
|
+
return { runtime: 'codex', status: 'failed', trusted: 0, reason: selection.fail };
|
|
204
229
|
}
|
|
205
|
-
|
|
230
|
+
const selected = selection.hooks;
|
|
231
|
+
// Converge every selected HQ hook to trusted AND enabled. A hook that is
|
|
206
232
|
// already trusted but was toggled off would otherwise silently stay
|
|
207
|
-
// disabled forever
|
|
208
|
-
const pending =
|
|
233
|
+
// disabled forever; reindex and install are the convergence points.
|
|
234
|
+
const pending = selected.filter((hook) => hook.trustStatus === 'untrusted' || hook.trustStatus === 'modified' || !hook.enabled);
|
|
209
235
|
if (pending.length === 0) {
|
|
210
236
|
return { runtime: 'codex', status: 'unchanged', trusted: 0 };
|
|
211
237
|
}
|
|
@@ -220,7 +246,7 @@ export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
|
|
|
220
246
|
edits: [{ keyPath: 'hooks.state', value: state, mergeStrategy: 'upsert' }],
|
|
221
247
|
reloadUserConfig: true,
|
|
222
248
|
});
|
|
223
|
-
const verified = hooksFromListResponse(await client.request('hooks/list', { cwds: [
|
|
249
|
+
const verified = hooksFromListResponse(await client.request('hooks/list', { cwds: [cwd] }));
|
|
224
250
|
const verifiedByKey = new Map(verified.hooks.map((hook) => [hook.key, hook]));
|
|
225
251
|
const stillPending = pending
|
|
226
252
|
.filter((hook) => {
|
|
@@ -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
|
*
|