@indigoai-us/hq-cli 5.129.0 → 5.130.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.130.0] — 2026-09-22
6
+
7
+ ### Fixed
8
+
9
+ - Hook-timeout reports remain visible as warning events with stable grouping and
10
+ diagnostic tags. Repeated reports are rate-limited while the next capture
11
+ records the suppressed count.
12
+
5
13
  ## [5.129.0] — 2026-09-22
6
14
 
7
15
  ### Added
@@ -483,7 +483,13 @@ export function registerCoreCommands(program) {
483
483
  .action(async (options) => {
484
484
  const stdin = await readSentryReportStdin(process.stdin);
485
485
  const timeoutMs = options.timeoutMs === undefined ? undefined : Number(options.timeoutMs);
486
- const result = await reportSentryEvent(stdin ?? "", { dryRun: options.dryRun, timeoutMs });
486
+ const scope = core.opts();
487
+ const hqRoot = scope.hqRoot ? resolveLiveRoot({ hqRoot: scope.hqRoot }) : undefined;
488
+ const result = await reportSentryEvent(stdin ?? "", {
489
+ dryRun: options.dryRun,
490
+ timeoutMs,
491
+ hqRoot,
492
+ });
487
493
  if (result.code !== 0)
488
494
  process.exitCode = result.code;
489
495
  });
@@ -3,14 +3,17 @@
3
3
  *
4
4
  * The stdin event schema is deliberately closed. Hook payloads can contain
5
5
  * commands, prompts, source content, transcript locations, and credentials, so
6
- * this module accepts only a small set of scalar diagnostics and never reads a
7
- * hook payload or the process environment. The owner-approved `hook_path`
6
+ * this module accepts only a small set of scalar diagnostics plus bounded hook
7
+ * history and never reads a hook payload or the process environment. The
8
+ * owner-approved `hook_path`
8
9
  * diagnostic is the narrow exception: it names the slow hook script and may
9
10
  * include its company directory, but never carries the hook payload itself.
10
11
  *
11
- * Invalid top-level input and structurally rich metadata are rejected rather
12
- * than truncated. Unknown scalar metadata is dropped instead: the allowlist,
13
- * not a caller version match, determines what may leave the machine.
12
+ * Invalid top-level input and structurally rich metadata are rejected. The
13
+ * bounded hook sequence is the deliberate exception: it keeps only the most
14
+ * recent entries so diagnostics cannot expand the transport payload. Unknown
15
+ * scalar metadata is dropped instead: the allowlist, not a caller version
16
+ * match, determines what may leave the machine.
14
17
  */
15
18
  import * as Sentry from "@sentry/node";
16
19
  import type { CaptureContext, SeverityLevel } from "@sentry/node";
@@ -24,9 +27,15 @@ export declare const MAX_SENTRY_REPORT_METADATA_VALUE_BYTES = 256;
24
27
  * is owner-approved telemetry for the exact hook script and may include a
25
28
  * company directory; it is never a substitute for hook input or a transcript.
26
29
  */
27
- export declare const ALLOWED_SENTRY_REPORT_METADATA_KEYS: readonly ["hook_name", "hook_path", "hook_event", "tool_name", "session_id", "declared_timeout_ms", "elapsed_ms", "remaining_ms", "watchdog_timeout_ms", "hook_timeout_ms", "hq_version", "platform", "load_average"];
30
+ export declare const ALLOWED_SENTRY_REPORT_METADATA_KEYS: readonly ["hook_name", "hook_path", "hook_event", "hook_script", "tool_name", "session_id", "declared_timeout_ms", "elapsed_ms", "remaining_ms", "watchdog_timeout_ms", "hook_timeout_ms", "hq_version", "platform", "load_average", "exit_code", "final_elapsed_ms", "bash_env_set", "cwd_kind", "shell", "nproc", "hook_sequence"];
28
31
  export declare const MAX_SENTRY_REPORT_METADATA_KEYS: number;
29
- export type SentryReportMetadata = Record<string, string | number | boolean>;
32
+ export type HookSequenceEntry = {
33
+ script: string;
34
+ event: string;
35
+ ms: number;
36
+ };
37
+ export type SentryReportMetadataValue = string | number | boolean | HookSequenceEntry[];
38
+ export type SentryReportMetadata = Record<string, SentryReportMetadataValue>;
30
39
  export type SentryReportEvent = {
31
40
  type: string;
32
41
  message: string;
@@ -38,15 +47,26 @@ export type SentryReportOptions = {
38
47
  dryRun?: boolean;
39
48
  /** Total time reserved for the report flush after validation. */
40
49
  timeoutMs?: number;
50
+ /** Explicit live HQ root supplied by `hq core --hq-root`. */
51
+ hqRoot?: string;
52
+ /** Test seam for the bounded timeout-report stamp store. */
53
+ rateLimitDirectory?: string;
54
+ /** Test seam for deterministic rate-limit windows. */
55
+ rateLimitNow?: () => number;
41
56
  };
42
57
  export type SentryReportDependencies = {
43
58
  sentry: {
44
59
  captureMessage: (message: string, context?: CaptureContext | SeverityLevel) => string;
60
+ addBreadcrumb: (breadcrumb: Parameters<typeof Sentry.addBreadcrumb>[0]) => void;
45
61
  metrics: Pick<typeof Sentry.metrics, "count">;
46
62
  flush: (timeout?: number) => PromiseLike<boolean>;
47
63
  };
48
64
  stdout: Pick<NodeJS.WriteStream, "write">;
49
65
  stderr: Pick<NodeJS.WriteStream, "write">;
66
+ /** Test seam; production resolves the live HQ workspace tmp directory. */
67
+ rateLimitDirectory?: string;
68
+ /** Test seam; production uses Date.now. */
69
+ rateLimitNow?: () => number;
50
70
  };
51
71
  export type SentryReportResult = {
52
72
  code: 0 | 1;
@@ -3,17 +3,26 @@
3
3
  *
4
4
  * The stdin event schema is deliberately closed. Hook payloads can contain
5
5
  * commands, prompts, source content, transcript locations, and credentials, so
6
- * this module accepts only a small set of scalar diagnostics and never reads a
7
- * hook payload or the process environment. The owner-approved `hook_path`
6
+ * this module accepts only a small set of scalar diagnostics plus bounded hook
7
+ * history and never reads a hook payload or the process environment. The
8
+ * owner-approved `hook_path`
8
9
  * diagnostic is the narrow exception: it names the slow hook script and may
9
10
  * include its company directory, but never carries the hook payload itself.
10
11
  *
11
- * Invalid top-level input and structurally rich metadata are rejected rather
12
- * than truncated. Unknown scalar metadata is dropped instead: the allowlist,
13
- * not a caller version match, determines what may leave the machine.
12
+ * Invalid top-level input and structurally rich metadata are rejected. The
13
+ * bounded hook sequence is the deliberate exception: it keeps only the most
14
+ * recent entries so diagnostics cannot expand the transport payload. Unknown
15
+ * scalar metadata is dropped instead: the allowlist, not a caller version
16
+ * match, determines what may leave the machine.
14
17
  */
18
+ import { createHash } from "node:crypto";
19
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { dirname, join } from "node:path";
15
22
  import * as Sentry from "@sentry/node";
23
+ import { CLI_VERSION } from "../../cli-version.js";
16
24
  import { beforeSend } from "../../sentry-before-send.js";
25
+ import { resolveLiveRoot } from "../../utils/hq-roots.js";
17
26
  export const DEFAULT_SENTRY_REPORT_TIMEOUT_MS = 750;
18
27
  export const MAX_SENTRY_REPORT_TIMEOUT_MS = 5_000;
19
28
  export const MAX_SENTRY_REPORT_PAYLOAD_BYTES = 2_048;
@@ -30,8 +39,17 @@ const SENTRY_REPORT_LEVELS = new Set([
30
39
  "debug",
31
40
  ]);
32
41
  const HOOK_TIMEOUT_WARNING_TYPE = "hook_timeout_warning";
42
+ const HOOK_TIMEOUT_EXCEEDED_TYPE = "hook_timeout_exceeded";
43
+ const HOOK_LATE_FINISH_TYPE = "hook_late_finish";
33
44
  const HOOK_TIMEOUT_WARNING_METRIC = "hq.hook_timeout_warning";
45
+ const HOOK_TIMEOUT_EXCEEDED_METRIC = "hq.hook_timeout_exceeded";
34
46
  const HOOK_TIMEOUT_EXCEEDED_FINGERPRINT = "hook-timeout-exceeded";
47
+ const HOOK_TIMEOUT_WARNING_FINGERPRINT = "hook-timeout";
48
+ const MAX_HOOK_SEQUENCE_ENTRIES = 20;
49
+ const HOOK_TIMEOUT_RATE_LIMIT_WINDOW_MS = 10 * 60 * 1_000;
50
+ const RATE_LIMIT_LOCK_WAIT_MS = 500;
51
+ const RATE_LIMIT_LOCK_POLL_MS = 5;
52
+ const RATE_LIMIT_LOCK_STALE_MS = 5_000;
35
53
  /**
36
54
  * The only hook diagnostics that can be attached as metadata. The list omits
37
55
  * command text, prompts, payloads, company names, and repositories. `hook_path`
@@ -42,6 +60,7 @@ export const ALLOWED_SENTRY_REPORT_METADATA_KEYS = [
42
60
  "hook_name",
43
61
  "hook_path",
44
62
  "hook_event",
63
+ "hook_script",
45
64
  "tool_name",
46
65
  "session_id",
47
66
  "declared_timeout_ms",
@@ -52,6 +71,13 @@ export const ALLOWED_SENTRY_REPORT_METADATA_KEYS = [
52
71
  "hq_version",
53
72
  "platform",
54
73
  "load_average",
74
+ "exit_code",
75
+ "final_elapsed_ms",
76
+ "bash_env_set",
77
+ "cwd_kind",
78
+ "shell",
79
+ "nproc",
80
+ "hook_sequence",
55
81
  ];
56
82
  const ALLOWED_METADATA_KEYS = new Set(ALLOWED_SENTRY_REPORT_METADATA_KEYS);
57
83
  const SENTRY_REPORT_METADATA_KEY_HEADROOM = 2;
@@ -96,7 +122,34 @@ function isScalarMetadataValue(value) {
96
122
  return true;
97
123
  return typeof value === "number" && Number.isFinite(value);
98
124
  }
125
+ function isHookSequenceEntry(value) {
126
+ if (!isRecord(value))
127
+ return false;
128
+ return (typeof value.script === "string" &&
129
+ value.script.length > 0 &&
130
+ byteLength(value.script) <= MAX_SENTRY_REPORT_METADATA_VALUE_BYTES &&
131
+ typeof value.event === "string" &&
132
+ value.event.length > 0 &&
133
+ byteLength(value.event) <= MAX_SENTRY_REPORT_METADATA_VALUE_BYTES &&
134
+ typeof value.ms === "number" &&
135
+ Number.isFinite(value.ms) &&
136
+ value.ms >= 0);
137
+ }
138
+ function boundedHookSequence(value) {
139
+ if (!Array.isArray(value))
140
+ return null;
141
+ const recent = value.slice(-MAX_HOOK_SEQUENCE_ENTRIES);
142
+ if (!recent.every(isHookSequenceEntry))
143
+ return null;
144
+ return recent.map((entry) => ({
145
+ script: basenameFromPath(entry.script),
146
+ event: entry.event,
147
+ ms: entry.ms,
148
+ }));
149
+ }
99
150
  function isAllowedMetadataValue(key, value) {
151
+ if (key === "hook_sequence")
152
+ return boundedHookSequence(value) !== null;
100
153
  if (!isScalarMetadataValue(value))
101
154
  return false;
102
155
  if (key === "tool_name" || key === "session_id" || key === "hook_path") {
@@ -157,15 +210,18 @@ export function parseSentryReportEvent(stdin) {
157
210
  const metadata = {};
158
211
  let droppedMetadataKeys = 0;
159
212
  for (const key of metadataKeys) {
160
- if (!isScalarMetadataValue(rawMetadata[key]))
161
- return invalidInput();
162
213
  if (!ALLOWED_METADATA_KEYS.has(key)) {
214
+ if (!isScalarMetadataValue(rawMetadata[key]))
215
+ return invalidInput();
163
216
  droppedMetadataKeys++;
164
217
  continue;
165
218
  }
166
219
  if (!isAllowedMetadataValue(key, rawMetadata[key]))
167
220
  return invalidInput();
168
- metadata[key] = rawMetadata[key];
221
+ metadata[key] =
222
+ key === "hook_sequence"
223
+ ? boundedHookSequence(rawMetadata[key])
224
+ : rawMetadata[key];
169
225
  }
170
226
  return {
171
227
  code: 0,
@@ -185,11 +241,139 @@ function resolveTimeout(timeoutMs) {
185
241
  return null;
186
242
  return timeout;
187
243
  }
188
- function sentryEnvelope(event, overrides) {
244
+ function isHookTimeoutEvent(event) {
245
+ return (event.type === HOOK_TIMEOUT_WARNING_TYPE ||
246
+ event.type === HOOK_TIMEOUT_EXCEEDED_TYPE ||
247
+ event.type === HOOK_LATE_FINISH_TYPE);
248
+ }
249
+ function stringMetadata(metadata, key, fallback = "unknown") {
250
+ const value = metadata[key];
251
+ return typeof value === "string" && value.length > 0 ? value : fallback;
252
+ }
253
+ function basenameFromPath(value) {
254
+ const normalized = value.replaceAll("\\", "/");
255
+ const last = normalized.slice(normalized.lastIndexOf("/") + 1);
256
+ return last || "unknown";
257
+ }
258
+ const SAFE_TAG_TEXT = /^[A-Za-z0-9][A-Za-z0-9._:/+() -]{0,127}$/;
259
+ const SAFE_HOOK_EVENT = /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/;
260
+ const SAFE_HOOK_SCRIPT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
261
+ const SAFE_EXIT_CODE = /^(?:-?[0-9]+|running)$/;
262
+ const SAFE_NPROC = /^[0-9]+$/;
263
+ function safeTagValue(value, pattern = SAFE_TAG_TEXT) {
264
+ return pattern.test(value) ? value : "unknown";
265
+ }
266
+ function safeMetadataTag(metadata, key, pattern = SAFE_TAG_TEXT) {
267
+ const value = metadata[key];
268
+ return typeof value === "string" && value.length > 0 ? safeTagValue(value, pattern) : "unknown";
269
+ }
270
+ function hookEvent(metadata) {
271
+ return safeMetadataTag(metadata, "hook_event", SAFE_HOOK_EVENT);
272
+ }
273
+ function hookScript(metadata) {
274
+ const explicit = stringMetadata(metadata, "hook_script", "");
275
+ const path = explicit || stringMetadata(metadata, "hook_path", "");
276
+ return path ? safeTagValue(basenameFromPath(path), SAFE_HOOK_SCRIPT) : "unknown";
277
+ }
278
+ function timeoutElapsedMs(metadata) {
279
+ const finalElapsed = metadata.final_elapsed_ms;
280
+ if (typeof finalElapsed === "number" && Number.isFinite(finalElapsed))
281
+ return finalElapsed;
282
+ const elapsed = metadata.elapsed_ms;
283
+ return typeof elapsed === "number" && Number.isFinite(elapsed) ? elapsed : undefined;
284
+ }
285
+ function formatNumber(value) {
286
+ return value.toFixed(2).replace(/\.00$|(?<=\.[0-9])0$/, "");
287
+ }
288
+ function elapsedBucket(elapsedMs) {
289
+ if (elapsedMs === undefined)
290
+ return "unknown";
291
+ if (elapsedMs < 5_000)
292
+ return "<5s";
293
+ if (elapsedMs < 10_000)
294
+ return "5-10s";
295
+ if (elapsedMs < 20_000)
296
+ return "10-20s";
297
+ if (elapsedMs < 60_000)
298
+ return "20-60s";
299
+ return ">=60s";
300
+ }
301
+ function loadBucket(loadAverage) {
302
+ if (loadAverage === undefined)
303
+ return "unknown";
304
+ if (loadAverage < 2)
305
+ return "<2";
306
+ if (loadAverage < 5)
307
+ return "2-5";
308
+ if (loadAverage < 10)
309
+ return "5-10";
310
+ if (loadAverage < 20)
311
+ return "10-20";
312
+ return ">=20";
313
+ }
314
+ function bashEnvTag(metadata) {
315
+ const value = metadata.bash_env_set;
316
+ if (value === true || value === "set")
317
+ return "set";
318
+ if (value === false || value === "unset")
319
+ return "unset";
320
+ return "unknown";
321
+ }
322
+ function hookTimeoutTags(event) {
323
+ const metadata = event.metadata;
324
+ const elapsedMs = timeoutElapsedMs(metadata);
325
+ const declaredTimeoutMs = typeof metadata.declared_timeout_ms === "number" && Number.isFinite(metadata.declared_timeout_ms)
326
+ ? metadata.declared_timeout_ms
327
+ : undefined;
328
+ const ratio = elapsedMs !== undefined && declaredTimeoutMs !== undefined && declaredTimeoutMs > 0
329
+ ? Math.round((elapsedMs / declaredTimeoutMs) * 4) / 4
330
+ : undefined;
331
+ const shell = stringMetadata(metadata, "shell");
332
+ const nproc = metadata.nproc;
333
+ const loadAverage = metadata.load_average;
334
+ return {
335
+ hook_event: hookEvent(metadata),
336
+ hook_script: hookScript(metadata),
337
+ elapsed_bucket: elapsedBucket(elapsedMs),
338
+ declared_timeout_s: declaredTimeoutMs === undefined ? "unknown" : formatNumber(declaredTimeoutMs / 1_000),
339
+ watchdog_ratio: ratio === undefined ? "unknown" : formatNumber(ratio),
340
+ exit_code: typeof metadata.exit_code === "string" || typeof metadata.exit_code === "number"
341
+ ? safeTagValue(String(metadata.exit_code), SAFE_EXIT_CODE)
342
+ : "unknown",
343
+ platform: safeMetadataTag(metadata, "platform"),
344
+ hq_cli_version: CLI_VERSION,
345
+ hq_core_version: safeMetadataTag(metadata, "hq_version"),
346
+ shell: safeTagValue(shell),
347
+ bash_env: bashEnvTag(metadata),
348
+ cwd_kind: safeMetadataTag(metadata, "cwd_kind"),
349
+ load_bucket: typeof loadAverage === "number" ? loadBucket(loadAverage) : "unknown",
350
+ nproc: typeof nproc === "number" || typeof nproc === "string"
351
+ ? safeTagValue(String(nproc), SAFE_NPROC)
352
+ : "unknown",
353
+ tool_name: safeMetadataTag(metadata, "tool_name"),
354
+ };
355
+ }
356
+ function hookTimeoutKind(event) {
357
+ if (event.type === HOOK_TIMEOUT_EXCEEDED_TYPE || event.type === HOOK_LATE_FINISH_TYPE) {
358
+ return "exceeded";
359
+ }
360
+ const elapsedMs = event.metadata.elapsed_ms;
361
+ const declaredTimeoutMs = event.metadata.declared_timeout_ms;
362
+ return typeof elapsedMs === "number" &&
363
+ typeof declaredTimeoutMs === "number" &&
364
+ elapsedMs >= declaredTimeoutMs
365
+ ? "exceeded"
366
+ : "warning";
367
+ }
368
+ function sentryEnvelope(event, overrides, additionalTags = {}) {
189
369
  return {
190
370
  level: overrides?.level ?? event.level,
191
- fingerprint: overrides?.fingerprint ?? [event.fingerprint],
192
- tags: { sentry_event_type: event.type },
371
+ fingerprint: overrides?.fingerprint ?? [safeTagValue(event.fingerprint)],
372
+ tags: {
373
+ sentry_event_type: safeTagValue(event.type),
374
+ ...(isHookTimeoutEvent(event) ? hookTimeoutTags(event) : {}),
375
+ ...additionalTags,
376
+ },
193
377
  extra: event.metadata,
194
378
  };
195
379
  }
@@ -201,25 +385,23 @@ function scrubbedSentryEnvelope(event, overrides) {
201
385
  }, {});
202
386
  }
203
387
  /**
204
- * The watchdog's relative threshold fires before the declared timeout and
205
- * clamps remaining_ms at zero, so its real warnings stay on the metric path.
206
- * Keep a defensive branch for a genuine exceeded payload from another
207
- * producer without restoring per-hook grouping.
388
+ * Keep timeout faults visible at warning level while grouping them by the
389
+ * hook event and installation-independent script basename. A genuine
390
+ * exceeded or late-finish payload gets its own stable condition group.
208
391
  */
209
392
  function hookTimeoutExceededOverrides(event) {
210
- if (event.type !== HOOK_TIMEOUT_WARNING_TYPE)
211
- return undefined;
212
- const elapsedMs = event.metadata.elapsed_ms;
213
- const declaredTimeoutMs = event.metadata.declared_timeout_ms;
214
- if (typeof elapsedMs !== "number" ||
215
- typeof declaredTimeoutMs !== "number" ||
216
- elapsedMs < declaredTimeoutMs) {
393
+ const eventName = hookEvent(event.metadata);
394
+ if (!isHookTimeoutEvent(event))
217
395
  return undefined;
396
+ if (hookTimeoutKind(event) === "warning") {
397
+ return {
398
+ level: "warning",
399
+ fingerprint: [HOOK_TIMEOUT_WARNING_FINGERPRINT, eventName, hookScript(event.metadata)],
400
+ };
218
401
  }
219
- const hookEvent = typeof event.metadata.hook_event === "string" ? event.metadata.hook_event : "unknown";
220
402
  return {
221
403
  level: "warning",
222
- fingerprint: [HOOK_TIMEOUT_EXCEEDED_FINGERPRINT, hookEvent],
404
+ fingerprint: [HOOK_TIMEOUT_EXCEEDED_FINGERPRINT, eventName, hookScript(event.metadata)],
223
405
  };
224
406
  }
225
407
  /**
@@ -240,6 +422,202 @@ async function flushWithin(sentry, timeoutMs) {
240
422
  });
241
423
  });
242
424
  }
425
+ function rateLimitDirectory(options, dependencies) {
426
+ if (options.rateLimitDirectory)
427
+ return options.rateLimitDirectory;
428
+ if (options.hqRoot)
429
+ return join(options.hqRoot, "workspace", "tmp", "sentry-hook-timeouts");
430
+ if (dependencies.rateLimitDirectory)
431
+ return dependencies.rateLimitDirectory;
432
+ try {
433
+ return join(resolveLiveRoot(), "workspace", "tmp", "sentry-hook-timeouts");
434
+ }
435
+ catch {
436
+ dependencies.stderr.write("Sentry timeout rate limit root unavailable; using process temp.\n");
437
+ return join(tmpdir(), "hq-cli-sentry-hook-timeouts");
438
+ }
439
+ }
440
+ function rateLimitStatePath(directory, key) {
441
+ const digest = createHash("sha256").update(key).digest("hex");
442
+ return join(directory, `${digest}.json`);
443
+ }
444
+ function readRateLimitState(statePath) {
445
+ try {
446
+ const parsed = JSON.parse(readFileSync(statePath, "utf8"));
447
+ if (typeof parsed.lastCapturedAt !== "number" ||
448
+ !Number.isFinite(parsed.lastCapturedAt) ||
449
+ typeof parsed.suppressedCount !== "number" ||
450
+ !Number.isInteger(parsed.suppressedCount) ||
451
+ parsed.suppressedCount < 0) {
452
+ throw new Error("invalid timeout rate limit state");
453
+ }
454
+ return parsed;
455
+ }
456
+ catch (error) {
457
+ if (error.code === "ENOENT")
458
+ return undefined;
459
+ throw new Error("Unable to read the timeout rate limit state", { cause: error });
460
+ }
461
+ }
462
+ function writeRateLimitState(statePath, state) {
463
+ mkdirSync(dirname(statePath), { recursive: true, mode: 0o700 });
464
+ const temporaryPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
465
+ writeFileSync(temporaryPath, `${JSON.stringify(state)}\n`, { encoding: "utf8", mode: 0o600 });
466
+ renameSync(temporaryPath, statePath);
467
+ }
468
+ function sleepForRateLimitLock(milliseconds) {
469
+ const signal = new Int32Array(new SharedArrayBuffer(4));
470
+ Atomics.wait(signal, 0, 0, milliseconds);
471
+ }
472
+ function releaseRateLimitLock(lockPath) {
473
+ try {
474
+ rmdirSync(lockPath);
475
+ }
476
+ catch (error) {
477
+ if (error.code !== "ENOENT")
478
+ throw error;
479
+ }
480
+ }
481
+ function acquireRateLimitLock(directory) {
482
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
483
+ const lockPath = join(directory, ".lock");
484
+ const deadline = Date.now() + RATE_LIMIT_LOCK_WAIT_MS;
485
+ for (;;) {
486
+ try {
487
+ mkdirSync(lockPath, { mode: 0o700 });
488
+ let released = false;
489
+ return () => {
490
+ if (released)
491
+ return;
492
+ released = true;
493
+ releaseRateLimitLock(lockPath);
494
+ };
495
+ }
496
+ catch (error) {
497
+ if (error.code !== "EEXIST")
498
+ throw error;
499
+ try {
500
+ if (Date.now() - statSync(lockPath).mtimeMs >= RATE_LIMIT_LOCK_STALE_MS) {
501
+ releaseRateLimitLock(lockPath);
502
+ continue;
503
+ }
504
+ }
505
+ catch (statError) {
506
+ if (statError.code !== "ENOENT")
507
+ throw statError;
508
+ continue;
509
+ }
510
+ if (Date.now() >= deadline)
511
+ throw new Error("Timed out acquiring the Sentry timeout rate limit lock");
512
+ sleepForRateLimitLock(RATE_LIMIT_LOCK_POLL_MS);
513
+ }
514
+ }
515
+ }
516
+ function pruneExpiredRateLimitState(directory, now, protectedStatePath) {
517
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
518
+ if (!entry.isFile() || !entry.name.endsWith(".json"))
519
+ continue;
520
+ const statePath = join(directory, entry.name);
521
+ if (statePath === protectedStatePath)
522
+ continue;
523
+ const state = readRateLimitState(statePath);
524
+ if (state &&
525
+ now >= state.lastCapturedAt &&
526
+ now - state.lastCapturedAt >= HOOK_TIMEOUT_RATE_LIMIT_WINDOW_MS) {
527
+ try {
528
+ unlinkSync(statePath);
529
+ }
530
+ catch (error) {
531
+ if (error.code !== "ENOENT")
532
+ throw error;
533
+ }
534
+ }
535
+ }
536
+ }
537
+ function timeoutRateLimitLease(event, kind, options, dependencies) {
538
+ const sessionId = stringMetadata(event.metadata, "session_id");
539
+ const key = `${sessionId}\0${hookEvent(event.metadata)}\0${hookScript(event.metadata)}\0${kind}`;
540
+ const directory = rateLimitDirectory(options, dependencies);
541
+ const statePath = rateLimitStatePath(directory, key);
542
+ const releaseLock = acquireRateLimitLock(directory);
543
+ const now = options.rateLimitNow?.() ?? dependencies.rateLimitNow?.() ?? Date.now();
544
+ try {
545
+ pruneExpiredRateLimitState(directory, now, statePath);
546
+ const current = readRateLimitState(statePath);
547
+ if (current &&
548
+ now >= current.lastCapturedAt &&
549
+ now - current.lastCapturedAt < HOOK_TIMEOUT_RATE_LIMIT_WINDOW_MS) {
550
+ writeRateLimitState(statePath, {
551
+ lastCapturedAt: current.lastCapturedAt,
552
+ suppressedCount: current.suppressedCount + 1,
553
+ });
554
+ releaseLock();
555
+ return {
556
+ capture: false,
557
+ suppressedSinceLast: 0,
558
+ commitCapture: () => undefined,
559
+ release: () => undefined,
560
+ };
561
+ }
562
+ let released = false;
563
+ const release = () => {
564
+ if (released)
565
+ return;
566
+ released = true;
567
+ releaseLock();
568
+ };
569
+ let committed = false;
570
+ return {
571
+ capture: true,
572
+ suppressedSinceLast: current?.suppressedCount ?? 0,
573
+ commitCapture: () => {
574
+ if (committed)
575
+ return;
576
+ if (released)
577
+ throw new Error("Sentry timeout rate limit lease was released before capture");
578
+ writeRateLimitState(statePath, { lastCapturedAt: now, suppressedCount: 0 });
579
+ committed = true;
580
+ release();
581
+ },
582
+ release,
583
+ };
584
+ }
585
+ catch (error) {
586
+ releaseLock();
587
+ throw error;
588
+ }
589
+ }
590
+ function metricAttributes(metadata) {
591
+ return Object.fromEntries(Object.entries(metadata)
592
+ .filter(([, value]) => isScalarMetadataValue(value))
593
+ .map(([key, value]) => [key, typeof value === "string" ? safeTagValue(value) : value]));
594
+ }
595
+ function addHookSequenceBreadcrumbs(event, dependencies) {
596
+ const sequence = event.metadata.hook_sequence;
597
+ if (!Array.isArray(sequence))
598
+ return;
599
+ for (const entry of sequence) {
600
+ dependencies.sentry.addBreadcrumb({
601
+ category: "hq.hook",
602
+ message: `${entry.script} ${entry.event} ${entry.ms}ms`,
603
+ });
604
+ }
605
+ }
606
+ async function reportSuppressedTimeout(result, event, timeoutMs, dependencies, kind) {
607
+ const metricName = kind === "exceeded" ? HOOK_TIMEOUT_EXCEEDED_METRIC : HOOK_TIMEOUT_WARNING_METRIC;
608
+ try {
609
+ dependencies.sentry.metrics.count(metricName, 1, {
610
+ attributes: metricAttributes(event.metadata),
611
+ });
612
+ if (await flushWithin(dependencies.sentry, timeoutMs))
613
+ return result;
614
+ }
615
+ catch {
616
+ // Report failure below without exposing event fields or transport details.
617
+ }
618
+ dependencies.stderr.write("Sentry report failed.\n");
619
+ return invalidInput();
620
+ }
243
621
  /**
244
622
  * Capture one validated Sentry message and wait only for the supplied bounded
245
623
  * flush budget. A zero result means the event entered the local transport and
@@ -266,15 +644,37 @@ export async function reportSentryEvent(stdin, options = {}, dependencies = defa
266
644
  dependencies.stdout.write(`${JSON.stringify(scrubbed)}\n`);
267
645
  return result;
268
646
  }
269
- if (result.event.type === HOOK_TIMEOUT_WARNING_TYPE && !overrides) {
647
+ if (isHookTimeoutEvent(result.event)) {
648
+ addHookSequenceBreadcrumbs(result.event, dependencies);
649
+ const kind = hookTimeoutKind(result.event);
650
+ let rateLimit;
270
651
  try {
271
- dependencies.sentry.metrics.count(HOOK_TIMEOUT_WARNING_METRIC, 1, {
272
- attributes: result.event.metadata,
273
- });
652
+ rateLimit = timeoutRateLimitLease(result.event, kind, options, dependencies);
653
+ }
654
+ catch {
655
+ dependencies.stderr.write("Sentry timeout rate limit state unavailable; sending without rate limiting.\n");
656
+ rateLimit = {
657
+ capture: true,
658
+ suppressedSinceLast: 0,
659
+ commitCapture: () => undefined,
660
+ release: () => undefined,
661
+ };
662
+ }
663
+ if (!rateLimit.capture) {
664
+ return reportSuppressedTimeout(result, result.event, timeoutMs, dependencies, kind);
665
+ }
666
+ const tags = rateLimit.suppressedSinceLast > 0
667
+ ? { suppressed_since_last: String(rateLimit.suppressedSinceLast) }
668
+ : {};
669
+ const envelope = sentryEnvelope(result.event, overrides, tags);
670
+ try {
671
+ dependencies.sentry.captureMessage(result.event.message, envelope);
672
+ rateLimit.commitCapture();
274
673
  if (await flushWithin(dependencies.sentry, timeoutMs))
275
674
  return result;
276
675
  }
277
676
  catch {
677
+ rateLimit.release();
278
678
  // Report failure below without exposing event fields or transport details.
279
679
  }
280
680
  dependencies.stderr.write("Sentry report failed.\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.129.0",
3
+ "version": "5.130.0",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {