@indigoai-us/hq-cli 5.287.0 → 5.288.0

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.
@@ -1,3 +1,122 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { probeProviderCompactionsSince, providerTranscriptSize, } from "./compaction.js";
5
+ import { LanesError } from "./errors.js";
1
6
  export const MESSAGE_REPLAY_STATE_NAME = "message-compaction-replay.json";
2
7
  export const MESSAGE_COMPACTION_REPLAY_NOTE = "These messages were delivered before a context compaction and still apply.";
8
+ export function readMessageReplayState(filePath, turnId, onLegacyFlag) {
9
+ const text = fs.readFileSync(filePath, "utf8");
10
+ let parsed;
11
+ try {
12
+ parsed = JSON.parse(text);
13
+ }
14
+ catch (err) {
15
+ throw new LanesError("message_replay_state_invalid", `Replay state ${filePath} is invalid JSON: ${err instanceof Error ? err.message : String(err)}`);
16
+ }
17
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
18
+ throw new LanesError("message_replay_state_invalid", `Replay state ${filePath} is not an object`);
19
+ }
20
+ const state = parsed;
21
+ if (state.schema_version !== 1 ||
22
+ state.turn_id !== turnId ||
23
+ (state.last_compaction_marker !== null &&
24
+ (!Number.isSafeInteger(state.last_compaction_marker) || Number(state.last_compaction_marker) < 0)) ||
25
+ (state.transcript_offset !== undefined &&
26
+ (!Number.isSafeInteger(state.transcript_offset) || Number(state.transcript_offset) < 0)) ||
27
+ (state.transcript_path !== undefined && typeof state.transcript_path !== "string") ||
28
+ (state.provider !== undefined && !isLaneProvider(state.provider)) ||
29
+ (state.flag_enabled !== undefined && typeof state.flag_enabled !== "boolean")) {
30
+ throw new LanesError("message_replay_state_invalid", `Replay state ${filePath} does not match the current provider turn`);
31
+ }
32
+ // Older state files persisted the removed per-turn flag. Accept it, but
33
+ // remove it before the next write so the stale cache does not live on.
34
+ if (state.flag_enabled !== undefined) {
35
+ delete state.flag_enabled;
36
+ onLegacyFlag?.();
37
+ }
38
+ return state;
39
+ }
40
+ function isLaneProvider(value) {
41
+ return value === "claude" || value === "codex" || value === "grok";
42
+ }
43
+ export function writeMessageReplayState(filePath, state) {
44
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
45
+ const tempPath = `${filePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
46
+ fs.writeFileSync(tempPath, `${JSON.stringify(state)}\n`, { mode: 0o600 });
47
+ try {
48
+ fs.renameSync(tempPath, filePath);
49
+ }
50
+ catch (err) {
51
+ fs.rmSync(tempPath, { force: true });
52
+ throw err;
53
+ }
54
+ }
55
+ function safeProviderTranscriptPath(transcriptPath, provider, env) {
56
+ if (provider === "grok")
57
+ return transcriptPath;
58
+ const home = provider === "claude" ? env.CLAUDE_CONFIG_DIR : env.CODEX_HOME;
59
+ if (!home) {
60
+ throw new LanesError("message_replay_provider_home_missing", `Cannot validate ${provider} transcript without its provider home`);
61
+ }
62
+ if (!path.isAbsolute(transcriptPath) || path.extname(transcriptPath) !== ".jsonl") {
63
+ throw new LanesError("message_replay_transcript_path_invalid", `The ${provider} hook transcript path must be an absolute JSONL path`);
64
+ }
65
+ const configuredHomePath = path.resolve(home);
66
+ const homePath = fs.realpathSync(home);
67
+ const candidate = path.resolve(transcriptPath);
68
+ const expectedRoot = provider === "claude" ? "projects" : "sessions";
69
+ const assertWithinProviderHome = (root, resolvedTranscript, message) => {
70
+ const relative = path.relative(root, resolvedTranscript);
71
+ if (relative === "" ||
72
+ relative === ".." ||
73
+ relative.startsWith(`..${path.sep}`) ||
74
+ path.isAbsolute(relative) ||
75
+ relative.split(path.sep)[0] !== expectedRoot) {
76
+ throw new LanesError("message_replay_transcript_outside_provider_home", message);
77
+ }
78
+ };
79
+ try {
80
+ const realTranscript = fs.realpathSync(candidate);
81
+ assertWithinProviderHome(homePath, realTranscript, `The ${provider} hook transcript resolves outside its ${expectedRoot} directory`);
82
+ return realTranscript;
83
+ }
84
+ catch (err) {
85
+ if (err.code === "ENOENT") {
86
+ assertWithinProviderHome(configuredHomePath, candidate, `The ${provider} hook transcript is outside its configured ${expectedRoot} directory`);
87
+ return candidate;
88
+ }
89
+ throw err;
90
+ }
91
+ }
92
+ export function updateMessageReplayTranscript(state, transcriptPath, provider, env, probe = probeProviderCompactionsSince) {
93
+ if (provider === "grok") {
94
+ state.provider = provider;
95
+ state.last_compaction_marker = 0;
96
+ return "unchanged";
97
+ }
98
+ const canonicalPath = safeProviderTranscriptPath(transcriptPath, provider, env);
99
+ const providerChanged = state.provider !== undefined && state.provider !== provider;
100
+ state.provider = provider;
101
+ if (providerChanged ||
102
+ state.last_compaction_marker === null ||
103
+ state.transcript_path !== canonicalPath ||
104
+ state.transcript_offset === undefined) {
105
+ state.last_compaction_marker = 0;
106
+ state.transcript_path = canonicalPath;
107
+ state.transcript_offset = providerTranscriptSize(canonicalPath);
108
+ return "baseline";
109
+ }
110
+ const delta = probe(canonicalPath, provider, state.transcript_offset);
111
+ state.transcript_offset = delta.next_offset;
112
+ if (delta.replaced) {
113
+ state.last_compaction_marker = 0;
114
+ return "replaced";
115
+ }
116
+ if (delta.count > 0) {
117
+ state.last_compaction_marker += delta.count;
118
+ return "compacted";
119
+ }
120
+ return "unchanged";
121
+ }
3
122
  //# sourceMappingURL=message-replay.js.map
@@ -37,6 +37,7 @@ import * as fs from "node:fs";
37
37
  import * as os from "node:os";
38
38
  import * as path from "node:path";
39
39
  import { resolveProcessFlagCompanyContext } from "./flag-registry.js";
40
+ import { isCliLimitResourceKind, queueCliLimitNoticeExposure, } from "../utils/cli-telemetry.js";
40
41
  import { planLimitUpgradeFallbackUrl, selectPlanLimitUpgradeUrl, } from "./billing/plan-limit-upgrade-url.js";
41
42
  export const PLAN_LIMIT_UPGRADE_URL = "https://hq.computer/billing/upgrade";
42
43
  /**
@@ -445,6 +446,9 @@ export function emitPlanLimitThresholdNotice(crossings, upgradeUrl, opts = {}) {
445
446
  thresholdsShownThisSession.set(key, crossing.firstSeenAt);
446
447
  printed[key] = crossing.firstSeenAt;
447
448
  write(chalk.yellow(renderThresholdNotice(crossing, resolvedUpgradeUrl)) + "\n");
449
+ if (isCliLimitResourceKind(crossing.resource)) {
450
+ queueCliLimitNoticeExposure(crossing.resource, "advisory");
451
+ }
448
452
  }
449
453
  if (Object.keys(printed).length > 0) {
450
454
  updateNagState(statePath, { notifiedThresholds: printed });
@@ -486,6 +490,10 @@ export function emitPlanLimitNag(opts = {}) {
486
490
  const overEntries = entries.filter(([, e]) => e.over);
487
491
  const line = buildOverLine(overEntries, resolvedUpgradeUrl);
488
492
  write(chalk.yellow(line) + "\n");
493
+ const resourceKind = overEntries[0]?.[0];
494
+ if (resourceKind && isCliLimitResourceKind(resourceKind)) {
495
+ queueCliLimitNoticeExposure(resourceKind, "advisory");
496
+ }
489
497
  writeShownAt(statePath, nowMs);
490
498
  return;
491
499
  }
@@ -498,6 +506,9 @@ export function emitPlanLimitNag(opts = {}) {
498
506
  warningShownThisSession = true;
499
507
  const line = `⚠ HQ Starter: ${formatEntryLine(worst.key, worst.entry)}. Add headroom before you hit the cap: ${resolvedUpgradeUrl}`;
500
508
  write(chalk.yellow(line) + "\n");
509
+ if (isCliLimitResourceKind(worst.key)) {
510
+ queueCliLimitNoticeExposure(worst.key, "advisory");
511
+ }
501
512
  }
502
513
  catch {
503
514
  // Never throw from emit path.
package/dist/main.js CHANGED
@@ -43,7 +43,7 @@ import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
43
43
  import { autoUpdateAndReexec } from "./utils/self-update.js";
44
44
  import { CLI_VERSION } from "./cli-version.js";
45
45
  import { findLazyCommand, registerCommandCatalog } from "./lazy-commands.js";
46
- import { emitCliFirstSyncNextStepOutcome, emitCliSessionStarted, emitPlanLimitInviteTeammateExposure, takeFirstSyncStepOutcomes, takePlanLimitInviteTeammateExposures, maybeShowFirstSyncNextStep, } from "./utils/cli-telemetry.js";
46
+ import { emitCliFirstSyncNextStepOutcome, emitCliLimitNoticeEngagement, emitCliLimitNoticeExposure, emitCliSessionStarted, emitPlanLimitInviteTeammateExposure, isCliLimitResourceKind, queueCliLimitNoticeEngagement, queueCliLimitNoticeExposure, takeFirstSyncStepOutcomes, takeCliLimitNoticeEngagements, takeCliLimitNoticeExposures, takePlanLimitInviteTeammateExposures, maybeShowFirstSyncNextStep, } from "./utils/cli-telemetry.js";
47
47
  import { reportCliClientHealthInvocation } from "./utils/client-health.js";
48
48
  import { settleWithin } from "./utils/settle-with-timeout.js";
49
49
  import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
@@ -295,11 +295,15 @@ export async function runCli() {
295
295
  const releaseHealthTimeoutMs = foregroundNetworkBudget.timeoutFor(FINALIZATION_FOREGROUND_WAIT_MS);
296
296
  const firstSyncOutcomeFlushes = takeFirstSyncStepOutcomes().map((outcome) => emitCliFirstSyncNextStepOutcome(outcome, releaseHealthTimeoutMs));
297
297
  const inviteTeammateExposureFlushes = takePlanLimitInviteTeammateExposures().map((companyUid) => emitPlanLimitInviteTeammateExposure(companyUid, releaseHealthTimeoutMs));
298
+ const limitNoticeExposureFlushes = takeCliLimitNoticeExposures().map((exposure) => emitCliLimitNoticeExposure(exposure, releaseHealthTimeoutMs));
299
+ const limitNoticeEngagementFlushes = takeCliLimitNoticeEngagements().map((engagement) => emitCliLimitNoticeEngagement(engagement, releaseHealthTimeoutMs));
298
300
  await settleWithin([
299
301
  refreshVersionCache({ timeoutMs: releaseHealthTimeoutMs }),
300
302
  Sentry.flush(releaseHealthTimeoutMs),
301
303
  ...firstSyncOutcomeFlushes,
302
304
  ...inviteTeammateExposureFlushes,
305
+ ...limitNoticeExposureFlushes,
306
+ ...limitNoticeEngagementFlushes,
303
307
  ], releaseHealthTimeoutMs);
304
308
  });
305
309
  // Last, so it wins over anything the (skipped) command path would have
@@ -419,6 +423,9 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
419
423
  const interactive = deps.interactive ?? canOfferTeamUpgrade(deps.argv);
420
424
  const message = formatPlanGateError(err, { urlOnly: !interactive });
421
425
  deps.stderr.write(interactive ? `hq: ${message}\n` : `${message}\n`);
426
+ const promptExposure = isCliLimitResourceKind(err.details.resource ?? "")
427
+ ? queueCliLimitNoticeExposure(err.details.resource, "refusal", err.details.companyUid)
428
+ : null;
422
429
  deps.setExitCode(1);
423
430
  if (interactive && err.details.upgradeUrl) {
424
431
  const companyUid = err.details.companyUid;
@@ -429,24 +436,28 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
429
436
  err.code === "BILLING_REQUIRED");
430
437
  if (isCompanyWorkforceCheckout && companyUid) {
431
438
  try {
432
- await offerTeamUpgrade(async () => {
439
+ const upgraded = await offerTeamUpgrade(async () => {
433
440
  await (deps.upgradeToTeam ?? upgradeToTeam)({ companyUid });
434
441
  }, {
435
442
  interactive: true,
436
443
  argv: deps.argv,
437
444
  ask: deps.ask,
438
445
  });
446
+ if (upgraded)
447
+ queueCliLimitNoticeEngagement(promptExposure);
439
448
  }
440
449
  catch (upgradeErr) {
441
450
  const message = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
442
451
  deps.stderr.write(`hq: ${message}\n`);
443
452
  try {
444
- await offerUpgradeLink(err.details.upgradeUrl, {
453
+ const opened = await offerUpgradeLink(err.details.upgradeUrl, {
445
454
  interactive: true,
446
455
  argv: deps.argv,
447
456
  ask: deps.ask,
448
457
  openUrl: deps.openUrl,
449
458
  });
459
+ if (opened)
460
+ queueCliLimitNoticeEngagement(promptExposure);
450
461
  }
451
462
  catch (linkErr) {
452
463
  const linkMessage = linkErr instanceof Error ? linkErr.message : String(linkErr);
@@ -456,12 +467,14 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
456
467
  }
457
468
  else {
458
469
  try {
459
- await offerUpgradeLink(err.details.upgradeUrl, {
470
+ const opened = await offerUpgradeLink(err.details.upgradeUrl, {
460
471
  interactive: true,
461
472
  argv: deps.argv,
462
473
  ask: deps.ask,
463
474
  openUrl: deps.openUrl,
464
475
  });
476
+ if (opened)
477
+ queueCliLimitNoticeEngagement(promptExposure);
465
478
  }
466
479
  catch (upgradeErr) {
467
480
  const message = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
@@ -10,6 +10,24 @@ export declare const TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when yo
10
10
  export declare const FIRST_SYNC_TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp; when enabled, first-sync guidance shown or followed and the flag key cli.first-sync-next-step; never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
11
11
  export declare const CLI_FEATURE_USED_TELEMETRY_FLAG = "cli.feature-used-telemetry-v1";
12
12
  export declare const CLI_FEATURE_USED_TELEMETRY_DISCLOSURE_LINE = "HQ sends a feature-use event when you run a supported CLI feature (feature kind and outcome, plus your company UID when available; never command names, file contents, or secrets). The hq-flags key cli.feature-used-telemetry-v1 controls these events. Set HQ_TELEMETRY=0 to turn this off.";
13
+ export declare const CLI_LIMIT_AT_ACTION_PROMPT_FLAG = "billing.limit-at-action-prompt";
14
+ export type CliLimitResourceKind = "users" | "integrations" | "secrets" | "agents" | "deployments" | "storageBytes";
15
+ export type CliLimitPresentationKind = "advisory" | "refusal";
16
+ export interface CliLimitNoticeExposure {
17
+ companyUid: string;
18
+ resourceKind: CliLimitResourceKind;
19
+ presentationKind: CliLimitPresentationKind;
20
+ exposureId: string;
21
+ }
22
+ export interface CliLimitNoticeEngagement extends CliLimitNoticeExposure {
23
+ action: "upgrade_clicked";
24
+ }
25
+ /** Capture an exposure only after its notice has been written to the terminal. */
26
+ export declare function queueCliLimitNoticeExposure(resourceKind: CliLimitResourceKind, presentationKind: CliLimitPresentationKind, resolvedCompanyUid?: string): CliLimitNoticeExposure | null;
27
+ /** Only an explicit, confirmed browser open is an upgrade engagement. */
28
+ export declare function queueCliLimitNoticeEngagement(exposure: CliLimitNoticeExposure | null): void;
29
+ export declare function takeCliLimitNoticeExposures(): CliLimitNoticeExposure[];
30
+ export declare function takeCliLimitNoticeEngagements(): CliLimitNoticeEngagement[];
13
31
  /** Queue one exposure after the CLI has printed the invite suggestion. */
14
32
  export declare function queuePlanLimitInviteTeammateExposure(companyUid: string): void;
15
33
  /** Drain invite-suggestion exposures for the bounded command-finalization flush. */
@@ -22,6 +40,7 @@ export interface CliFeatureUsage {
22
40
  /** Only company UIDs are accepted; names and slugs are never telemetry fields. */
23
41
  companyUid?: string;
24
42
  }
43
+ export declare function isCliLimitResourceKind(value: string): value is CliLimitResourceKind;
25
44
  /**
26
45
  * Emit one bounded, privacy-limited feature-use event with a cached identity.
27
46
  * It never refreshes credentials or turns telemetry failure into command failure.
@@ -55,4 +74,6 @@ export declare function emitCliSessionStarted(timeoutMs?: number): Promise<void>
55
74
  /** Emit one authenticated measurement for a first-sync prompt outcome. */
56
75
  export declare function emitCliFirstSyncNextStepOutcome(outcome: FirstSyncStepOutcome, timeoutMs?: number): Promise<void>;
57
76
  export declare function emitPlanLimitInviteTeammateExposure(companyUid: string, timeoutMs?: number): Promise<void>;
77
+ export declare function emitCliLimitNoticeExposure(exposure: CliLimitNoticeExposure, timeoutMs?: number): Promise<void>;
78
+ export declare function emitCliLimitNoticeEngagement(engagement: CliLimitNoticeEngagement, timeoutMs?: number): Promise<void>;
58
79
  //# sourceMappingURL=cli-telemetry.d.ts.map
@@ -4,7 +4,7 @@ import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { CLI_VERSION } from "../cli-version.js";
6
6
  import { isExpiring, isMachineIdentity, loadCachedTokens, loadMachineCachedTokens, } from "./cognito-session.js";
7
- import { resolveProcessFlagGate } from "../lib/flag-registry.js";
7
+ import { resolveProcessFlagCompanyContext, resolveProcessFlagGate, } from "../lib/flag-registry.js";
8
8
  import { vaultApiFetch } from "./vault-api.js";
9
9
  import { firstSyncStepTelemetryProperties, isFirstSyncNextStepEnabled, isHumanInteractiveCliSession, } from "../lib/first-sync-next-step.js";
10
10
  export { CLI_FIRST_SYNC_NEXT_STEP_FLAG, FIRST_SYNC_CONFIRMATION_COPY, FIRST_SYNC_NEXT_STEP_COPY, claimFirstSyncStepMarker, firstSyncStepMarkerPaths, firstSyncStepTelemetryProperties, isFirstSyncNextStepEnabled, isHumanInteractiveCliSession, isWorkBearingSyncResult, maybeShowFirstSyncNextStep, recordFirstWorkBearingSync, shouldConfirmFirstWorkBearingSync, shouldShowFirstSyncNextStep, takeFirstSyncStepOutcomes, } from "../lib/first-sync-next-step.js";
@@ -24,6 +24,38 @@ const FIRST_SYNC_DISCLOSURE_MARKER = "cli-telemetry-disclosed-first-sync-v1";
24
24
  const FEATURE_USED_DISCLOSURE_MARKER = "cli-telemetry-disclosed-feature-used-v1";
25
25
  let cliSessionStartedPromise;
26
26
  const pendingPlanLimitInviteTeammateExposures = [];
27
+ export const CLI_LIMIT_AT_ACTION_PROMPT_FLAG = "billing.limit-at-action-prompt";
28
+ const pendingCliLimitNoticeExposures = [];
29
+ const pendingCliLimitNoticeEngagements = [];
30
+ /** Capture an exposure only after its notice has been written to the terminal. */
31
+ export function queueCliLimitNoticeExposure(resourceKind, presentationKind, resolvedCompanyUid) {
32
+ try {
33
+ if (!resolveProcessFlagGate(CLI_LIMIT_AT_ACTION_PROMPT_FLAG, { fallback: false }, () => false)) {
34
+ return null;
35
+ }
36
+ const companyUid = resolvedCompanyUid ?? resolveProcessFlagCompanyContext().companyUid;
37
+ if (!companyUid || !COMPANY_UID_PATTERN.test(companyUid))
38
+ return null;
39
+ const exposure = { companyUid, resourceKind, presentationKind, exposureId: randomUUID() };
40
+ pendingCliLimitNoticeExposures.push(exposure);
41
+ return exposure;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ /** Only an explicit, confirmed browser open is an upgrade engagement. */
48
+ export function queueCliLimitNoticeEngagement(exposure) {
49
+ if (!exposure)
50
+ return;
51
+ pendingCliLimitNoticeEngagements.push({ ...exposure, action: "upgrade_clicked" });
52
+ }
53
+ export function takeCliLimitNoticeExposures() {
54
+ return pendingCliLimitNoticeExposures.splice(0, pendingCliLimitNoticeExposures.length);
55
+ }
56
+ export function takeCliLimitNoticeEngagements() {
57
+ return pendingCliLimitNoticeEngagements.splice(0, pendingCliLimitNoticeEngagements.length);
58
+ }
27
59
  /** Queue one exposure after the CLI has printed the invite suggestion. */
28
60
  export function queuePlanLimitInviteTeammateExposure(companyUid) {
29
61
  const normalizedCompanyUid = companyUid.trim();
@@ -36,6 +68,9 @@ export function takePlanLimitInviteTeammateExposures() {
36
68
  return pendingPlanLimitInviteTeammateExposures.splice(0, pendingPlanLimitInviteTeammateExposures.length);
37
69
  }
38
70
  const COMPANY_UID_PATTERN = /^cmp_[A-Za-z0-9_-]{1,128}$/;
71
+ export function isCliLimitResourceKind(value) {
72
+ return ["users", "integrations", "secrets", "agents", "deployments", "storageBytes"].includes(value);
73
+ }
39
74
  /**
40
75
  * Emit one bounded, privacy-limited feature-use event with a cached identity.
41
76
  * It never refreshes credentials or turns telemetry failure into command failure.
@@ -382,4 +417,69 @@ export async function emitPlanLimitInviteTeammateExposure(companyUid, timeoutMs
382
417
  logPlanLimitInviteTeammateExposureFailure(error);
383
418
  }
384
419
  }
420
+ /** Send one bounded plan-limit prompt event through authenticated CLI telemetry. */
421
+ async function emitCliLimitNoticeEvent(exposure, eventName, action, timeoutMs = TELEMETRY_TIMEOUT_MS) {
422
+ try {
423
+ if (isCliTelemetryDisabled())
424
+ return;
425
+ const machineIdentity = isMachineIdentity();
426
+ const cached = machineIdentity ? loadMachineCachedTokens() : loadCachedTokens();
427
+ if (!cached ||
428
+ isExpiring(cached, 120) ||
429
+ !isHumanInteractiveCliSession({
430
+ stdoutIsTTY: Boolean(process.stdout.isTTY),
431
+ argv: process.argv,
432
+ env: process.env,
433
+ machineIdentity,
434
+ }))
435
+ return;
436
+ const token = machineIdentity ? cached.idToken : cached.accessToken;
437
+ if (!token)
438
+ return;
439
+ const controller = new AbortController();
440
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
441
+ try {
442
+ await vaultApiFetch({
443
+ token,
444
+ path: "/v1/telemetry/events",
445
+ method: "POST",
446
+ body: {
447
+ events: [{
448
+ eventName,
449
+ app: "hq-cli",
450
+ source: "cli",
451
+ occurredAt: new Date().toISOString(),
452
+ consentBasis: "no-consent",
453
+ schemaVersion: 2,
454
+ companyUid: exposure.companyUid,
455
+ idempotencyKey: randomUUID(),
456
+ properties: {
457
+ surface: "cli_limit_notice",
458
+ resourceKind: exposure.resourceKind,
459
+ presentationKind: exposure.presentationKind,
460
+ exposureId: exposure.exposureId,
461
+ ...(eventName === "plan_limit_prompt_engaged" && action ? { action } : {}),
462
+ },
463
+ }],
464
+ },
465
+ signal: controller.signal,
466
+ });
467
+ }
468
+ catch {
469
+ // Measurement must never change a command's output or exit status.
470
+ }
471
+ finally {
472
+ clearTimeout(timeout);
473
+ }
474
+ }
475
+ catch {
476
+ // Identity, flag cache, and transport failures all remain best effort.
477
+ }
478
+ }
479
+ export function emitCliLimitNoticeExposure(exposure, timeoutMs = TELEMETRY_TIMEOUT_MS) {
480
+ return emitCliLimitNoticeEvent(exposure, "plan_limit_prompt_exposed", undefined, timeoutMs);
481
+ }
482
+ export function emitCliLimitNoticeEngagement(engagement, timeoutMs = TELEMETRY_TIMEOUT_MS) {
483
+ return emitCliLimitNoticeEvent(engagement, "plan_limit_prompt_engaged", engagement.action, timeoutMs);
484
+ }
385
485
  //# sourceMappingURL=cli-telemetry.js.map
@@ -0,0 +1,3 @@
1
+ /** Capture only fixed, aggregatable auth diagnostics; never attach tokens or identity. */
2
+ export declare function reportNoninteractivePersonRefreshRejection(error: unknown): Promise<void>;
3
+ //# sourceMappingURL=cognito-refresh-sentry.d.ts.map
@@ -0,0 +1,49 @@
1
+ import { CLI_VERSION } from "../cli-version.js";
2
+ import { CognitoRefreshError } from "@indigoai-us/hq-cloud";
3
+ /** Capture only fixed, aggregatable auth diagnostics; never attach tokens or identity. */
4
+ export async function reportNoninteractivePersonRefreshRejection(error) {
5
+ try {
6
+ // Keep the Sentry SDK and its generated DSN out of ordinary session utility
7
+ // imports. The reporter is reached only after a definitive noninteractive
8
+ // refresh rejection, so load the Sentry boundary only on that path.
9
+ const { Sentry } = await import("../sentry.js");
10
+ const statusCode = error instanceof CognitoRefreshError ? error.statusCode : undefined;
11
+ const rejectionKind = error instanceof CognitoRefreshError
12
+ ? "cognito_refresh_rejection"
13
+ : "classified_auth_error";
14
+ Sentry.withScope((scope) => {
15
+ scope.setUser(null);
16
+ scope.clearBreadcrumbs();
17
+ scope.addBreadcrumb({
18
+ category: "hq.auth",
19
+ message: "Noninteractive person refresh was attempted for an expiring session",
20
+ level: "info",
21
+ data: { interactive: false },
22
+ });
23
+ scope.addBreadcrumb({
24
+ category: "hq.auth",
25
+ message: "Refresh rejection had no fresh same-principal cache generation",
26
+ level: "error",
27
+ data: {
28
+ rejection_kind: rejectionKind,
29
+ ...(Number.isInteger(statusCode) ? { status_code: statusCode } : {}),
30
+ },
31
+ });
32
+ scope.setLevel("error");
33
+ scope.setFingerprint(["hq-cli", "person-refresh-rejected"]);
34
+ scope.setTag("component", "person-session-refresh");
35
+ scope.setTag("auth.rejection_kind", rejectionKind);
36
+ scope.setTag("auth.fresh_cache_generation_found", "false");
37
+ scope.setTag("platform", process.platform);
38
+ scope.setTag("cli_version", CLI_VERSION);
39
+ if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599) {
40
+ scope.setTag("auth.status_code", String(statusCode));
41
+ }
42
+ Sentry.captureMessage("Noninteractive HQ person session refresh rejected");
43
+ });
44
+ }
45
+ catch {
46
+ // Telemetry is best-effort: it must never prevent session invalidation or auth fallback.
47
+ }
48
+ }
49
+ //# sourceMappingURL=cognito-refresh-sentry.js.map
@@ -30,6 +30,7 @@ import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
30
30
  import { daemonDir } from "../lib/mesh/live/daemon/paths.js";
31
31
  import { workMeshRoot } from "../lib/mesh/live/paths.js";
32
32
  import { acquireLockFileExclusively } from "./exclusive-lock.js";
33
+ import { reportNoninteractivePersonRefreshRejection } from "./cognito-refresh-sentry.js";
33
34
  // Re-export the cached-session primitives for callers that must deliberately
34
35
  // avoid the refresh/login behavior in the higher-level helpers below.
35
36
  export { isExpiring, isMachineIdentity, loadCachedTokens };
@@ -879,6 +880,37 @@ export function isDefinitivePersonAuthRejection(err) {
879
880
  function markPersonSessionRejected(accessToken) {
880
881
  invalidateCachedTokensByFingerprint(accessTokenFingerprint(accessToken));
881
882
  }
883
+ async function reportPersonRefreshRejectionBestEffort(error) {
884
+ try {
885
+ await reportNoninteractivePersonRefreshRejection(error);
886
+ }
887
+ catch {
888
+ // Reporting is deliberately non-blocking for auth recovery and invalidation.
889
+ }
890
+ }
891
+ /**
892
+ * A refresh rejection applies to the snapshot sent to Cognito. Reread the
893
+ * shared person cache before rejecting the session because another process
894
+ * may have atomically installed a fresh generation while the request ran.
895
+ */
896
+ function resolvePersonSessionAfterRefreshRejection(cached) {
897
+ const current = readPersonTokenFile();
898
+ const cachedKey = cognitoSubjectKey(cached);
899
+ const currentKey = cognitoSubjectKey(current);
900
+ if (!current || current.accessToken === cached.accessToken)
901
+ return { kind: "none" };
902
+ if (cachedKey && currentKey && cachedKey !== currentKey) {
903
+ return {
904
+ kind: "identity-swap",
905
+ error: new IdentitySwapRefusal("refusing rejected refresh for " + formatCognitoSubject(cached) +
906
+ ": the active token file now belongs to " + formatCognitoSubject(current)),
907
+ };
908
+ }
909
+ if (cachedKey && currentKey === cachedKey && !isExpiring(current, 120)) {
910
+ return { kind: "fresh", tokens: current };
911
+ }
912
+ return { kind: "none" };
913
+ }
882
914
  /**
883
915
  * Return a non-expired Cognito access token, refreshing or browser-logging-in
884
916
  * as needed. Person cache lives at ~/.hq/cognito-tokens.json. Machine minting
@@ -931,6 +963,15 @@ async function ensureCognitoTokenUnserialized(options) {
931
963
  if (!isDefinitivePersonAuthRejection(err)) {
932
964
  throw err;
933
965
  }
966
+ const resolution = resolvePersonSessionAfterRefreshRejection(cached);
967
+ if (resolution.kind === "identity-swap")
968
+ throw resolution.error;
969
+ if (resolution.kind === "fresh") {
970
+ return resolution.tokens.accessToken;
971
+ }
972
+ if (!interactive) {
973
+ await reportPersonRefreshRejectionBestEffort(err);
974
+ }
934
975
  markPersonSessionRejected(cached.accessToken);
935
976
  const fallback = await tryDefaultMachineCredsFallback(options);
936
977
  if (fallback)
@@ -1007,6 +1048,13 @@ async function ensureCognitoIdTokenUnserialized(options) {
1007
1048
  if (!isDefinitivePersonAuthRejection(err)) {
1008
1049
  throw err;
1009
1050
  }
1051
+ const resolution = resolvePersonSessionAfterRefreshRejection(cached);
1052
+ if (resolution.kind === "identity-swap")
1053
+ throw resolution.error;
1054
+ if (resolution.kind === "fresh")
1055
+ return resolution.tokens.idToken;
1056
+ if (!interactive)
1057
+ await reportPersonRefreshRejectionBestEffort(err);
1010
1058
  markPersonSessionRejected(cached.accessToken);
1011
1059
  const fallback = await tryDefaultMachineCredsFallback(options);
1012
1060
  if (fallback)
@@ -1123,6 +1171,13 @@ async function refreshCachedSessionUnserialized(options) {
1123
1171
  reason: err instanceof Error ? err.message : String(err),
1124
1172
  };
1125
1173
  }
1174
+ const resolution = resolvePersonSessionAfterRefreshRejection(cached);
1175
+ if (resolution.kind === "identity-swap") {
1176
+ return { refreshed: false, reason: resolution.error.message };
1177
+ }
1178
+ if (resolution.kind === "fresh")
1179
+ return { refreshed: true };
1180
+ await reportPersonRefreshRejectionBestEffort(err);
1126
1181
  // Definitive rejection — person session is dead; mark it and fall back.
1127
1182
  markPersonSessionRejected(cached.accessToken);
1128
1183
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.287.0",
3
+ "version": "5.288.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {