@indigoai-us/hq-cli 5.345.44 → 5.345.46

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/command-catalog.generated.d.ts +17 -1
  3. package/dist/command-catalog.generated.js +21 -1
  4. package/dist/commands/access.js +6 -4
  5. package/dist/commands/bot.js +1 -1
  6. package/dist/commands/db-destroy.js +5 -4
  7. package/dist/commands/install-global.d.ts +3 -0
  8. package/dist/commands/install-global.js +24 -2
  9. package/dist/commands/lanes-message.js +84 -51
  10. package/dist/commands/lanes-resume.js +44 -0
  11. package/dist/commands/lanes-shared.d.ts +25 -0
  12. package/dist/commands/lanes.d.ts +4 -0
  13. package/dist/commands/lanes.js +253 -15
  14. package/dist/commands/meetings.js +4 -1
  15. package/dist/lib/anywhere-person-setting.d.ts +11 -0
  16. package/dist/lib/anywhere-person-setting.js +19 -0
  17. package/dist/lib/daemon/ops/mcp.d.ts +10 -1
  18. package/dist/lib/daemon/ops/mcp.js +17 -10
  19. package/dist/lib/daemon/run.js +8 -2
  20. package/dist/lib/doctor/checks/sync-runtime.d.ts +2 -0
  21. package/dist/lib/doctor/checks/sync-runtime.js +26 -12
  22. package/dist/lib/flag-snapshot-file.js +33 -25
  23. package/dist/lib/install/claude-targets.js +7 -2
  24. package/dist/lib/install/codex-targets.d.ts +6 -0
  25. package/dist/lib/install/codex-targets.js +25 -0
  26. package/dist/lib/lanes/flag-keys.d.ts +1 -0
  27. package/dist/lib/lanes/flag-keys.js +1 -0
  28. package/dist/lib/lanes/focus-guard.d.ts +65 -0
  29. package/dist/lib/lanes/focus-guard.js +91 -0
  30. package/dist/lib/lanes/outpost-dispatch.d.ts +1 -0
  31. package/dist/lib/lanes/outpost-dispatch.js +1 -0
  32. package/dist/lib/lanes/store.js +29 -0
  33. package/dist/lib/lanes/story-ledger.d.ts +14 -0
  34. package/dist/lib/lanes/story-ledger.js +46 -0
  35. package/dist/lib/lanes/types.d.ts +10 -2
  36. package/dist/lib/lanes/types.js +4 -3
  37. package/dist/utils/hook-trust.d.ts +7 -0
  38. package/dist/utils/hook-trust.js +40 -14
  39. package/dist/utils/package-use-lease.d.ts +1 -0
  40. package/dist/utils/package-use-lease.js +29 -0
  41. package/dist/utils/self-update-backoff.d.ts +17 -0
  42. package/dist/utils/self-update-backoff.js +73 -0
  43. package/dist/utils/self-update.d.ts +7 -0
  44. package/dist/utils/self-update.js +36 -0
  45. package/dist/utils/vault-api.d.ts +33 -1
  46. package/dist/utils/vault-api.js +55 -5
  47. package/dist/utils/version-gate.d.ts +13 -0
  48. package/dist/utils/version-gate.js +35 -5
  49. package/package.json +1 -1
@@ -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) => {
@@ -48,6 +48,7 @@ export declare function currentPackageUseUpdatePending(): boolean;
48
48
  export declare function currentPackageUseUpdateRequested(): boolean;
49
49
  export declare const __test__: {
50
50
  waitForUpdateRequest: typeof waitForUpdateRequest;
51
+ setPackageUseLeaseHeartbeatIntervalMs(intervalMs: number): void;
51
52
  setCurrentPackageUseLease(lease: PackageUseLease | null): void;
52
53
  };
53
54
  /**
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { utimes } from "node:fs/promises";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
  import { fileURLToPath } from "node:url";
@@ -8,6 +9,8 @@ const CLI_NAME = "@indigoai-us/hq-cli";
8
9
  const UPDATE_REQUEST = "update.pending.json";
9
10
  export const PACKAGE_USE_LEASE_HANDOFF_ENV = "HQ_CLI_PACKAGE_USE_LEASE_HANDOFF";
10
11
  const HANDOFF_TIMEOUT_MS = 2_000;
12
+ const PACKAGE_USE_LEASE_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000;
13
+ let packageUseLeaseHeartbeatIntervalMs = PACKAGE_USE_LEASE_HEARTBEAT_INTERVAL_MS;
11
14
  export const PACKAGE_USE_LEASE_PURPOSES = [
12
15
  "command",
13
16
  "resident-preload",
@@ -180,10 +183,32 @@ export function acquirePackageUseLease(prefix, hqVersion, stateDirectory, purpos
180
183
  const leasePath = path.join(directory, `${record.pid}-${record.start_time_ms}.json`);
181
184
  const tempPath = `${leasePath}.${Math.random().toString(36).slice(2)}.tmp`;
182
185
  let released = false;
186
+ let heartbeatInFlight = false;
187
+ let heartbeat = null;
188
+ const startHeartbeat = () => {
189
+ heartbeat = setInterval(() => {
190
+ if (released || heartbeatInFlight)
191
+ return;
192
+ heartbeatInFlight = true;
193
+ const now = new Date();
194
+ void utimes(leasePath, now, now)
195
+ .catch((error) => {
196
+ if (!released) {
197
+ process.stderr.write(`hq-cli package-use lease heartbeat failed (${errorLabel(error)})\n`);
198
+ }
199
+ })
200
+ .finally(() => {
201
+ heartbeatInFlight = false;
202
+ });
203
+ }, packageUseLeaseHeartbeatIntervalMs);
204
+ heartbeat.unref();
205
+ };
183
206
  const release = () => {
184
207
  if (released)
185
208
  return;
186
209
  released = true;
210
+ if (heartbeat)
211
+ clearInterval(heartbeat);
187
212
  process.removeListener("exit", releaseOnExit);
188
213
  try {
189
214
  rmSync(leasePath, { force: true });
@@ -204,6 +229,7 @@ export function acquirePackageUseLease(prefix, hqVersion, stateDirectory, purpos
204
229
  throw new Error(`Unable to publish the HQ CLI package-use lease (${errorLabel(error)})`, { cause: error });
205
230
  }
206
231
  if (!exists(requestPath)) {
232
+ startHeartbeat();
207
233
  process.once("exit", releaseOnExit);
208
234
  return { leasePath, release };
209
235
  }
@@ -409,6 +435,9 @@ export function currentPackageUseUpdateRequested() {
409
435
  }
410
436
  export const __test__ = {
411
437
  waitForUpdateRequest,
438
+ setPackageUseLeaseHeartbeatIntervalMs(intervalMs) {
439
+ packageUseLeaseHeartbeatIntervalMs = intervalMs;
440
+ },
412
441
  setCurrentPackageUseLease(lease) {
413
442
  currentPackageUseLease = lease;
414
443
  },
@@ -0,0 +1,17 @@
1
+ export declare const SELF_UPDATE_FAILURE_BACKOFF_MS: number;
2
+ export interface SelfUpdateFailureRecord {
3
+ /** Version the failed attempt tried to install. */
4
+ target: string;
5
+ /** Epoch milliseconds of the failed attempt. */
6
+ failedAt: number;
7
+ }
8
+ export interface SelfUpdateBackoff {
9
+ /** The recorded failure when it is still inside the backoff window, else null. */
10
+ active(now?: number): SelfUpdateFailureRecord | null;
11
+ record(target: string, now?: number): void;
12
+ clear(): void;
13
+ }
14
+ export declare function selfUpdateBackoffPath(homeDir?: string): string;
15
+ /** File-backed backoff under `~/.hq`. Every operation is best-effort. */
16
+ export declare function fileSelfUpdateBackoff(homeDir?: string): SelfUpdateBackoff;
17
+ //# sourceMappingURL=self-update-backoff.d.ts.map
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Cross-target backoff for automatic CLI self-updates.
3
+ *
4
+ * The older loop guard in `version-check.ts` (`markLatestIneffective`) only
5
+ * suppresses the exact npm `latest` that failed, and only when the version
6
+ * cache file exists and still names that version. HQ publishes several
7
+ * releases a day, and the hq-pro "update recommended" path hands the updater a
8
+ * version that need not match the cache. Either way the next invocation saw a
9
+ * "new" target and ran the same failing install again, so a broken package
10
+ * manager made every `hq` command retry the update.
11
+ *
12
+ * This record is keyed on the failure itself, not on the target version: after
13
+ * any failed or ineffective automatic update, automatic updates pause for
14
+ * {@link SELF_UPDATE_FAILURE_BACKOFF_MS} whatever version is newest. An explicit
15
+ * `hq rescue` ignores the record and clears it when it succeeds.
16
+ */
17
+ import * as fs from "node:fs";
18
+ import * as os from "node:os";
19
+ import * as path from "node:path";
20
+ export const SELF_UPDATE_FAILURE_BACKOFF_MS = 24 * 60 * 60 * 1000;
21
+ export function selfUpdateBackoffPath(homeDir = os.homedir()) {
22
+ return path.join(homeDir, ".hq", "self-update-backoff.json");
23
+ }
24
+ function readRecord(file) {
25
+ try {
26
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
27
+ if (typeof parsed.target !== "string" || typeof parsed.failedAt !== "number")
28
+ return null;
29
+ if (!Number.isFinite(parsed.failedAt))
30
+ return null;
31
+ return { target: parsed.target, failedAt: parsed.failedAt };
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ /** File-backed backoff under `~/.hq`. Every operation is best-effort. */
38
+ export function fileSelfUpdateBackoff(homeDir) {
39
+ const file = selfUpdateBackoffPath(homeDir);
40
+ return {
41
+ active(now = Date.now()) {
42
+ const record = readRecord(file);
43
+ if (!record)
44
+ return null;
45
+ const age = now - record.failedAt;
46
+ // A record from the future (clock moved back) is treated as fresh only
47
+ // within one window, so a bad clock cannot disable updates forever.
48
+ if (age < -SELF_UPDATE_FAILURE_BACKOFF_MS)
49
+ return null;
50
+ return age <= SELF_UPDATE_FAILURE_BACKOFF_MS ? record : null;
51
+ },
52
+ record(target, now = Date.now()) {
53
+ try {
54
+ fs.mkdirSync(path.dirname(file), { recursive: true });
55
+ const tmp = `${file}.${process.pid}.tmp`;
56
+ fs.writeFileSync(tmp, `${JSON.stringify({ target, failedAt: now })}\n`);
57
+ fs.renameSync(tmp, file);
58
+ }
59
+ catch {
60
+ // A write failure only means the next invocation may retry once.
61
+ }
62
+ },
63
+ clear() {
64
+ try {
65
+ fs.rmSync(file, { force: true });
66
+ }
67
+ catch {
68
+ // Best-effort.
69
+ }
70
+ },
71
+ };
72
+ }
73
+ //# sourceMappingURL=self-update-backoff.js.map
@@ -59,6 +59,7 @@
59
59
  import { spawn } from "node:child_process";
60
60
  import * as fs from "node:fs";
61
61
  import { probePnpmVersion, type RunningInstall, type UpdateResult } from "./version-gate.js";
62
+ import { type SelfUpdateBackoff } from "./self-update-backoff.js";
62
63
  import { type RestartDaemonResult } from "../lib/mesh/live/daemon/install.js";
63
64
  /**
64
65
  * Set on the re-exec'd child so it can never self-update (and re-exec) again.
@@ -167,6 +168,12 @@ export interface SelfUpdateDeps {
167
168
  * stops auto-retrying it. Defaults to {@link markLatestIneffective}.
168
169
  */
169
170
  markIneffective?: (version: string) => void;
171
+ /**
172
+ * Cross-target failure backoff. After a failed or ineffective automatic
173
+ * update, automatic updates pause for 24 hours. Defaults to a record under
174
+ * `~/.hq` (see `self-update-backoff.ts`).
175
+ */
176
+ backoff?: SelfUpdateBackoff;
170
177
  /**
171
178
  * Whether a human is watching this invocation. Defaults to "stderr is a TTY",
172
179
  * which is false for exactly the callers that must not replace the CLI
@@ -69,6 +69,7 @@ import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, bu
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";
72
+ import { fileSelfUpdateBackoff } from "./self-update-backoff.js";
72
73
  import { restartDaemonServiceIfInstalled } from "../lib/mesh/live/daemon/install.js";
73
74
  import { createCurrentPackageUseLeaseHandoff, PACKAGE_USE_LEASE_HANDOFF_ENV, releaseCurrentPackageUseLease, } from "./package-use-lease.js";
74
75
  /**
@@ -847,9 +848,44 @@ function sleepForRetry(ms) {
847
848
  }
848
849
  async function updateAndReexec(argv, flavor, known, deps) {
849
850
  const env = deps.env ?? process.env;
851
+ // Recursion guard: a process started by an update (the re-exec'd command, a
852
+ // package-manager lifecycle script, or anything those spawn) carries this
853
+ // marker and must never start another update.
850
854
  if (env[REEXEC_GUARD_ENV] === "1" || env.HQ_NO_UPDATE_CHECK === "1") {
851
855
  return { action: "skipped" };
852
856
  }
857
+ // A failed automatic update pauses automatic updates for every target, not
858
+ // just the one that failed: HQ ships several releases a day, so a target-keyed
859
+ // guard let each new release re-run the same broken install on every
860
+ // command. `hq rescue` is an explicit request and ignores the backoff.
861
+ const backoff = deps.backoff ?? fileSelfUpdateBackoff(deps.homeDir);
862
+ const explicit = flavor.noun === "rescue";
863
+ if (!explicit && backoff.active()) {
864
+ return { action: "skipped" };
865
+ }
866
+ let outcome;
867
+ try {
868
+ outcome = await attemptUpdateAndReexec(argv, flavor, known, deps, env);
869
+ }
870
+ catch (error) {
871
+ if (known)
872
+ backoff.record(known);
873
+ else
874
+ backoff.record("unknown");
875
+ throw error;
876
+ }
877
+ if (outcome.action === "update-failed" || outcome.action === "update-ineffective") {
878
+ backoff.record(outcome.latest ?? known ?? "unknown");
879
+ }
880
+ else if (outcome.action === "updated" ||
881
+ outcome.action === "reexec" ||
882
+ outcome.action === "updated-no-reexec" ||
883
+ outcome.action === "current") {
884
+ backoff.clear();
885
+ }
886
+ return outcome;
887
+ }
888
+ async function attemptUpdateAndReexec(argv, flavor, known, deps, env) {
853
889
  const latest = known ?? (await (deps.fetchLatest ?? fetchLatestVersion)());
854
890
  if (!latest)
855
891
  return { action: "skipped" };
@@ -77,7 +77,39 @@ export declare function getCompanyUid(token: string, companySlug: string | undef
77
77
  baseUrl?: string, options?: {
78
78
  allowOfflineScopeFallback?: boolean;
79
79
  }): Promise<string>;
80
- export declare function resolveCallerPersonUid(token: string, baseUrl?: string): Promise<string>;
80
+ /**
81
+ * The `agt_*` uid an agent machine token names, or null for any other token.
82
+ * An agent ID token carries `custom:entityType=agent` and
83
+ * `custom:entityUid=agt_*`; a person token carries neither. The claim is read
84
+ * without verification and only picks which identity the CLI resolves locally;
85
+ * hq-pro verifies the token and authorizes every request itself.
86
+ */
87
+ export declare function agentUidFromToken(token: string): string | null;
88
+ export type CallerIdentity = {
89
+ kind: 'person';
90
+ uid: string;
91
+ } | {
92
+ kind: 'agent';
93
+ uid: string;
94
+ };
95
+ /**
96
+ * Resolve the caller's own principal uid. An agent machine token resolves to
97
+ * its `agt_*` uid with no network call (agents have no person entity); any
98
+ * other token resolves to its canonical `prs_*` uid exactly as
99
+ * {@link resolveCallerPersonUid} does. Use this where the server accepts an
100
+ * agent principal; use `resolveCallerPersonUid` where only a person can act.
101
+ */
102
+ export declare function resolveCallerUid(token: string, baseUrl?: string): Promise<CallerIdentity>;
103
+ /** Thrown when a person-only command runs under an agent machine identity. */
104
+ export declare function agentIdentityUnsupportedError(agentUid: string, command?: string): Error;
105
+ /**
106
+ * Resolve the caller's canonical `prs_*` uid for a person-only command. An
107
+ * agent machine token fails fast with a clear "not available to agent
108
+ * identities" error instead of the person lookup's "No person entity found".
109
+ */
110
+ export declare function resolveCallerPersonUid(token: string, baseUrl?: string, opts?: {
111
+ command?: string;
112
+ }): Promise<string>;
81
113
  export declare function getEntityUid(token: string, opts: {
82
114
  personal?: boolean;
83
115
  companySlug?: string;
@@ -7,6 +7,7 @@ import { markExpectedUserError } from './expected-cli-error.js';
7
7
  import { redactErrorText } from './redact-error-text.js';
8
8
  import { isSecretLoadUnavailable, isUnavailableSecretLoadHttpStatus, SecretLoadUnavailableError } from './secret-load-availability.js';
9
9
  import { readCachedScopeUid, writeCachedScopeUid } from './secrets-cache.js';
10
+ import { peekIdToken } from './id-token.js';
10
11
  /**
11
12
  * Identity / company resolution lookups must never hang forever. These small
12
13
  * GETs run BEFORE a command does its real work (e.g. `hq secrets env` resolves
@@ -679,9 +680,57 @@ baseUrl, options = {}) {
679
680
  return cachedUid;
680
681
  }
681
682
  }
683
+ const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/;
684
+ /**
685
+ * The `agt_*` uid an agent machine token names, or null for any other token.
686
+ * An agent ID token carries `custom:entityType=agent` and
687
+ * `custom:entityUid=agt_*`; a person token carries neither. The claim is read
688
+ * without verification and only picks which identity the CLI resolves locally;
689
+ * hq-pro verifies the token and authorizes every request itself.
690
+ */
691
+ export function agentUidFromToken(token) {
692
+ const claims = peekIdToken(token);
693
+ const entityUid = claims['custom:entityUid'];
694
+ if (typeof entityUid !== 'string' || !AGENT_UID_PATTERN.test(entityUid))
695
+ return null;
696
+ const entityType = claims['custom:entityType'];
697
+ if (entityType !== undefined && entityType !== 'agent')
698
+ return null;
699
+ return entityUid;
700
+ }
701
+ /**
702
+ * Resolve the caller's own principal uid. An agent machine token resolves to
703
+ * its `agt_*` uid with no network call (agents have no person entity); any
704
+ * other token resolves to its canonical `prs_*` uid exactly as
705
+ * {@link resolveCallerPersonUid} does. Use this where the server accepts an
706
+ * agent principal; use `resolveCallerPersonUid` where only a person can act.
707
+ */
708
+ export async function resolveCallerUid(token, baseUrl) {
709
+ const agentUid = agentUidFromToken(token);
710
+ if (agentUid)
711
+ return { kind: 'agent', uid: agentUid };
712
+ return { kind: 'person', uid: await lookupCallerPersonUid(token, baseUrl) };
713
+ }
714
+ /** Thrown when a person-only command runs under an agent machine identity. */
715
+ export function agentIdentityUnsupportedError(agentUid, command) {
716
+ const what = command ? `\`${command}\`` : 'This command';
717
+ return markExpectedUserError(new Error(`${what} is not available to agent identities (signed in as agent ${agentUid}). ` +
718
+ 'It acts for a person; run it from a person\'s HQ login.'));
719
+ }
720
+ /**
721
+ * Resolve the caller's canonical `prs_*` uid for a person-only command. An
722
+ * agent machine token fails fast with a clear "not available to agent
723
+ * identities" error instead of the person lookup's "No person entity found".
724
+ */
725
+ export async function resolveCallerPersonUid(token, baseUrl, opts = {}) {
726
+ const agentUid = agentUidFromToken(token);
727
+ if (agentUid)
728
+ throw agentIdentityUnsupportedError(agentUid, opts.command);
729
+ return lookupCallerPersonUid(token, baseUrl);
730
+ }
682
731
  // Same selection rule as the backend's `resolveCallerPersonUid`: ascending by
683
732
  // createdAt, tie-break by uid ascending. Returns the `prs_*` UID.
684
- export async function resolveCallerPersonUid(token, baseUrl) {
733
+ async function lookupCallerPersonUid(token, baseUrl) {
685
734
  const res = await vaultApiFetch({
686
735
  token,
687
736
  path: '/entity/by-type/person',
@@ -708,16 +757,17 @@ export async function resolveCallerPersonUid(token, baseUrl) {
708
757
  });
709
758
  return persons[0].uid;
710
759
  }
711
- // Resolves the scope UID (cmp_* or prs_*) for a secrets command. Precedence:
712
- // `--personal` → caller's canonical person entity; else `--company <slug>` →
760
+ // Resolves the scope UID (cmp_*, prs_* or agt_*) for a secrets command.
761
+ // Precedence: `--personal` → caller's own scope (canonical person entity, or an
762
+ // agent's own `agt_*` vault, which hq-pro treats as agent-self scope); else `--company <slug>` →
713
763
  // resolved company UID; else fallback to single active company membership.
714
764
  export async function getEntityUid(token, opts, options = {}) {
715
765
  if (opts.personal) {
716
766
  if (!options.allowOfflineScopeFallback)
717
- return resolveCallerPersonUid(token);
767
+ return (await resolveCallerUid(token)).uid;
718
768
  const principalUid = cognitoCachePrincipalUid(token);
719
769
  try {
720
- const uid = await resolveCallerPersonUid(token);
770
+ const { uid } = await resolveCallerUid(token);
721
771
  if (principalUid)
722
772
  writeCachedScopeUid("personal", principalUid, uid);
723
773
  return uid;
@@ -47,6 +47,19 @@ export interface VersionCheckResponse {
47
47
  downloadUrl?: string;
48
48
  message?: string;
49
49
  }
50
+ /**
51
+ * Environment for every package-manager child an update starts.
52
+ *
53
+ * - The recursion guard stops anything the install runs (lifecycle scripts,
54
+ * a postinstall that calls `hq`) from starting a second update.
55
+ * - The Corepack settings keep an update from changing the user's package
56
+ * manager setup. With `COREPACK_DEFAULT_TO_LATEST` unset, Corepack resolves a
57
+ * missing default from the registry's newest release and writes it to its
58
+ * global lastKnownGood file, which moved users from pnpm 10 to pnpm 12.
59
+ * Project selection and auto-pinning are off so the caller's working
60
+ * directory cannot pick, or be rewritten with, a package-manager version.
61
+ */
62
+ export declare function packageManagerChildEnv(base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
50
63
  export declare function npmPrefixFromPackageDir(pkgDir: string): string | null;
51
64
  /**
52
65
  * Whether the running package lives inside a pnpm-managed **global** install.
@@ -55,7 +55,30 @@ export function getVersionGatePhase() {
55
55
  return currentVersionGatePhase;
56
56
  }
57
57
  function isOptedOut() {
58
- return process.env.HQ_NO_UPDATE_CHECK === "1";
58
+ // A process started by an update (re-exec, package-manager lifecycle script)
59
+ // carries the recursion guard and must never start another install.
60
+ return process.env.HQ_NO_UPDATE_CHECK === "1" || process.env[UPDATE_RECURSION_GUARD_ENV] === "1";
61
+ }
62
+ /**
63
+ * Environment for every package-manager child an update starts.
64
+ *
65
+ * - The recursion guard stops anything the install runs (lifecycle scripts,
66
+ * a postinstall that calls `hq`) from starting a second update.
67
+ * - The Corepack settings keep an update from changing the user's package
68
+ * manager setup. With `COREPACK_DEFAULT_TO_LATEST` unset, Corepack resolves a
69
+ * missing default from the registry's newest release and writes it to its
70
+ * global lastKnownGood file, which moved users from pnpm 10 to pnpm 12.
71
+ * Project selection and auto-pinning are off so the caller's working
72
+ * directory cannot pick, or be rewritten with, a package-manager version.
73
+ */
74
+ export function packageManagerChildEnv(base = process.env) {
75
+ return {
76
+ ...base,
77
+ [UPDATE_RECURSION_GUARD_ENV]: "1",
78
+ COREPACK_DEFAULT_TO_LATEST: "0",
79
+ COREPACK_ENABLE_PROJECT_SPEC: "0",
80
+ COREPACK_ENABLE_AUTO_PIN: "0",
81
+ };
59
82
  }
60
83
  function findCliPackageRoot(startDir) {
61
84
  let dir = startDir;
@@ -608,6 +631,7 @@ export function pnpmUpdateEnv(install, base = process.env) {
608
631
  // project, which can silently switch the update subprocess to that version.
609
632
  COREPACK_ENABLE_PROJECT_SPEC: "0",
610
633
  COREPACK_DEFAULT_TO_LATEST: "0",
634
+ COREPACK_ENABLE_AUTO_PIN: "0",
611
635
  };
612
636
  if (!base.PNPM_HOME)
613
637
  result.PNPM_HOME = home;
@@ -723,10 +747,8 @@ export function probePnpmVersion(cmd, args, env, cwd) {
723
747
  export function performPnpmUpdate(install, runner = runUpdateCommand, env = process.env, probe = probePnpmVersion) {
724
748
  const cwd = mkdtempSync(path.join(os.tmpdir(), "hq-cli-pnpm-update-"));
725
749
  const updateEnv = {
726
- ...env,
727
- COREPACK_ENABLE_PROJECT_SPEC: "0",
750
+ ...packageManagerChildEnv(env),
728
751
  COREPACK_ENABLE_STRICT: "0",
729
- COREPACK_DEFAULT_TO_LATEST: "0",
730
752
  npm_config_manage_package_manager_versions: "false",
731
753
  };
732
754
  try {
@@ -1125,6 +1147,14 @@ export function openInstallOutput(verbose, isTty = process.stdout.isTTY === true
1125
1147
  };
1126
1148
  }
1127
1149
  export function runSupervisedUpdateCommand(cmd, args, output, env, cwd) {
1150
+ // This process was itself started by an update. Starting another install
1151
+ // from here is how one failed update turns into a process storm, so refuse.
1152
+ if (process.env[UPDATE_RECURSION_GUARD_ENV] === "1") {
1153
+ const detail = "refusing to start a package-manager install from inside another hq update " +
1154
+ `(${UPDATE_RECURSION_GUARD_ENV}=1 is set)`;
1155
+ console.error(chalk.yellow(`hq: ${detail}.`));
1156
+ return { ok: false, code: "EHQUPDATERECURSION", detail };
1157
+ }
1128
1158
  try {
1129
1159
  const plan = buildSpawnPlan(cmd, args);
1130
1160
  const timeoutMs = updateTimeoutMs(env);
@@ -1147,7 +1177,7 @@ export function runSupervisedUpdateCommand(cmd, args, output, env, cwd) {
1147
1177
  }),
1148
1178
  ], inOwnProcessGroup({
1149
1179
  stdio: output.stdio,
1150
- ...(env ? { env } : {}),
1180
+ env: packageManagerChildEnv(env ?? process.env),
1151
1181
  ...(cwd ? { cwd } : {}),
1152
1182
  }));
1153
1183
  // spawnSync reports a missing executable via `error`, not a throw.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.44",
3
+ "version": "5.345.46",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {