@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.
@@ -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
- * `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());
@@ -53,6 +53,13 @@ export interface RuntimeHookTrustResult {
53
53
  export declare function createCodexAppServerClient(cwd: string, executable?: string, args?: string[]): Promise<CodexRpcClient>;
54
54
  /** Trust only hooks declared by this HQ root's project `.codex/` layer. */
55
55
  export declare function trustCodexProjectHooks(hqRoot: string, deps?: HookTrustDependencies): Promise<RuntimeHookTrustResult>;
56
+ /**
57
+ * Trust the user-scope hooks `hq install --global --runtime codex` registered
58
+ * in ~/.codex/hooks.json, named by their Codex keys (`<file>:<event>:<group>:<hook>`).
59
+ * Codex skips untrusted hooks, so without this HQ stays inert in every session
60
+ * outside the HQ folder. Other hooks in the same file are never trusted here.
61
+ */
62
+ export declare function trustCodexUserHooks(cwd: string, keys: readonly string[], deps?: HookTrustDependencies): Promise<RuntimeHookTrustResult>;
56
63
  /**
57
64
  * Mark this HQ root as a trusted Claude Code workspace.
58
65
  *
@@ -181,10 +181,37 @@ export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
181
181
  if (!fs.existsSync(path.join(hqRoot, '.codex'))) {
182
182
  return { runtime: 'codex', status: 'skipped', trusted: 0, reason: 'project .codex layer absent' };
183
183
  }
184
+ return trustCodexHooks(hqRoot, deps, (hooks) => {
185
+ const projectHooks = hqProjectHooks(hqRoot, hooks);
186
+ return projectHooks.length === 0
187
+ ? { skip: 'no HQ project hooks discovered' }
188
+ : { hooks: projectHooks };
189
+ });
190
+ }
191
+ /**
192
+ * Trust the user-scope hooks `hq install --global --runtime codex` registered
193
+ * in ~/.codex/hooks.json, named by their Codex keys (`<file>:<event>:<group>:<hook>`).
194
+ * Codex skips untrusted hooks, so without this HQ stays inert in every session
195
+ * outside the HQ folder. Other hooks in the same file are never trusted here.
196
+ */
197
+ export async function trustCodexUserHooks(cwd, keys, deps = DEFAULT_DEPS) {
198
+ if (keys.length === 0) {
199
+ return { runtime: 'codex', status: 'skipped', trusted: 0, reason: 'no HQ user hooks registered' };
200
+ }
201
+ return trustCodexHooks(cwd, deps, (hooks) => {
202
+ const byKey = new Map(hooks.filter((hook) => hook.source === 'user' && !hook.isManaged).map((hook) => [hook.key, hook]));
203
+ const missing = keys.filter((key) => !byKey.has(key));
204
+ if (missing.length > 0)
205
+ return { fail: `Codex did not discover: ${missing.join(', ')}` };
206
+ return { hooks: keys.map((key) => byKey.get(key)) };
207
+ });
208
+ }
209
+ /** List hooks for `cwd`, trust and enable the selected ones, then verify. */
210
+ async function trustCodexHooks(cwd, deps, select) {
184
211
  let client;
185
212
  try {
186
- client = await deps.createCodexClient(hqRoot);
187
- const discovered = hooksFromListResponse(await client.request('hooks/list', { cwds: [hqRoot] }));
213
+ client = await deps.createCodexClient(cwd);
214
+ const discovered = hooksFromListResponse(await client.request('hooks/list', { cwds: [cwd] }));
188
215
  if (discovered.errors.length > 0) {
189
216
  return {
190
217
  runtime: 'codex',
@@ -193,19 +220,18 @@ export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
193
220
  reason: discovered.errors.join('; '),
194
221
  };
195
222
  }
196
- const projectHooks = hqProjectHooks(hqRoot, discovered.hooks);
197
- if (projectHooks.length === 0) {
198
- return {
199
- runtime: 'codex',
200
- status: 'skipped',
201
- trusted: 0,
202
- reason: 'no HQ project hooks discovered',
203
- };
223
+ const selection = select(discovered.hooks);
224
+ if ('skip' in selection) {
225
+ return { runtime: 'codex', status: 'skipped', trusted: 0, reason: selection.skip };
226
+ }
227
+ if ('fail' in selection) {
228
+ return { runtime: 'codex', status: 'failed', trusted: 0, reason: selection.fail };
204
229
  }
205
- // Converge every HQ project hook to trusted AND enabled. A hook that is
230
+ const selected = selection.hooks;
231
+ // Converge every selected HQ hook to trusted AND enabled. A hook that is
206
232
  // already trusted but was toggled off would otherwise silently stay
207
- // disabled forever — reindex is the convergence point, so it re-enables.
208
- const pending = projectHooks.filter((hook) => hook.trustStatus === 'untrusted' || hook.trustStatus === 'modified' || !hook.enabled);
233
+ // disabled forever; reindex and install are the convergence points.
234
+ const pending = selected.filter((hook) => hook.trustStatus === 'untrusted' || hook.trustStatus === 'modified' || !hook.enabled);
209
235
  if (pending.length === 0) {
210
236
  return { runtime: 'codex', status: 'unchanged', trusted: 0 };
211
237
  }
@@ -220,7 +246,7 @@ export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
220
246
  edits: [{ keyPath: 'hooks.state', value: state, mergeStrategy: 'upsert' }],
221
247
  reloadUserConfig: true,
222
248
  });
223
- const verified = hooksFromListResponse(await client.request('hooks/list', { cwds: [hqRoot] }));
249
+ const verified = hooksFromListResponse(await client.request('hooks/list', { cwds: [cwd] }));
224
250
  const verifiedByKey = new Map(verified.hooks.map((hook) => [hook.key, hook]));
225
251
  const stillPending = pending
226
252
  .filter((hook) => {
@@ -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
  *