@indigoai-us/hq-cli 5.103.17 → 5.103.19

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,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.103.19] — 2026-08-22
6
+
7
+ ### Fixed
8
+
9
+ - `hq search` (and its `--mode semantic`/`hybrid` siblings) no longer files a
10
+ Sentry crash when the qmd child is stopped by the host — for example when the
11
+ out-of-memory killer stops a CPU-only embedding run on a small box, or an
12
+ operator interrupts the search. The spawn seam now keeps the terminating
13
+ signal instead of discarding it, so such a run is reported to the operator as
14
+ an actionable, signal-named line (tailored to whether a search or an index
15
+ build was killed) and is no longer captured as an hq-cli defect. Because the
16
+ synthesized message no longer interpolates the caller's query, it can no longer
17
+ mint a brand-new permanent Sentry issue per distinct query. Crash-class signals
18
+ (SIGSEGV/SIGABRT/SIGBUS), which usually indicate a genuine native fault, and
19
+ every non-zero qmd exit are unchanged and still reported. (Sentry 7677702704)
20
+
21
+ ## [5.103.18] — 2026-08-22
22
+
23
+ ### Fixed
24
+
25
+ - The bundled outpost CLI now runs remote commands as `ec2-user` by default.
26
+ The elevated `root` execution path is available only with an explicit
27
+ `hq outposts exec --root` request.
28
+
5
29
  ## [5.103.17] — 2026-08-22
6
30
 
7
31
  ### Fixed
@@ -3,6 +3,13 @@ export type QmdProcessResult = {
3
3
  stdout: string;
4
4
  stderr: string;
5
5
  error?: Error;
6
+ /**
7
+ * The signal that terminated the child, when qmd was KILLED rather than
8
+ * exited (spawnSync then reports `status: null`, `signal: <name>`). OPTIONAL
9
+ * so every existing runner and test double keeps compiling unchanged; read by
10
+ * finishRunQmd to classify a signalled qmd (HQ-CLI 7677702704).
11
+ */
12
+ signal?: NodeJS.Signals | string | null;
6
13
  };
7
14
  export type QmdProcessRunner = (bin: string, args: string[], options: {
8
15
  cwd?: string;
@@ -43,6 +50,7 @@ export type QmdSpawn = (cmd: string, args: string[], options: {
43
50
  stdout?: string;
44
51
  stderr?: string;
45
52
  error?: Error;
53
+ signal?: NodeJS.Signals | string | null;
46
54
  };
47
55
  /**
48
56
  * Prefix `execDir` onto the child env PATH so a node-entry launcher can re-spawn
@@ -79,6 +87,27 @@ export declare class QmdCollectionExistsError extends QmdExitError {
79
87
  name: string;
80
88
  constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, collectionName: string);
81
89
  }
90
+ /**
91
+ * qmd did NOT exit — it was KILLED BY A SIGNAL before it finished (spawnSync
92
+ * reports `status: null` with a non-null `signal`). hq-cli passes qmd no timeout
93
+ * and never signals the child itself, so the signal always originates OUTSIDE
94
+ * hq-cli: most often the host OOM killer during CPU-only embedding on a small
95
+ * box, or an operator interrupt (Ctrl-C, a terminated shell). `signal` carries
96
+ * the signal name so the top-level handler can name it and branch its remedy.
97
+ *
98
+ * Before this class a signalled qmd rendered as `exited with an unknown status:
99
+ * <qmd's raw stderr>` — and because that stderr was often purely advisory (an
100
+ * embeddings tip, a GPU warning) and the message interpolated the caller's whole
101
+ * argv, every distinct search query minted a brand-new permanent Sentry issue
102
+ * (HQ-CLI 7677702704 — the same unbounded-fingerprint failure fixed for
103
+ * HQ-CLI-S). This typed subclass lets the boundary classify the condition as the
104
+ * caller's environment and stop fingerprinting on the query text.
105
+ */
106
+ export declare class QmdTerminatedError extends QmdExitError {
107
+ readonly signal: string;
108
+ name: string;
109
+ constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, signal: string);
110
+ }
82
111
  export type ResolveQmdBinOptions = {
83
112
  env?: Record<string, string | undefined>;
84
113
  isExecutable?: (candidate: string) => boolean;
@@ -5,6 +5,7 @@ import * as os from 'node:os';
5
5
  import * as path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js';
8
+ import { redactErrorText } from '../../utils/redact-error-text.js';
8
9
  import { planCommandSpawn } from '../../utils/windows-spawn.js';
9
10
  const require = createRequire(import.meta.url);
10
11
  /** The `path` implementation for a (possibly injected) platform. */
@@ -61,6 +62,30 @@ export class QmdCollectionExistsError extends QmdExitError {
61
62
  this.collectionName = collectionName;
62
63
  }
63
64
  }
65
+ /**
66
+ * qmd did NOT exit — it was KILLED BY A SIGNAL before it finished (spawnSync
67
+ * reports `status: null` with a non-null `signal`). hq-cli passes qmd no timeout
68
+ * and never signals the child itself, so the signal always originates OUTSIDE
69
+ * hq-cli: most often the host OOM killer during CPU-only embedding on a small
70
+ * box, or an operator interrupt (Ctrl-C, a terminated shell). `signal` carries
71
+ * the signal name so the top-level handler can name it and branch its remedy.
72
+ *
73
+ * Before this class a signalled qmd rendered as `exited with an unknown status:
74
+ * <qmd's raw stderr>` — and because that stderr was often purely advisory (an
75
+ * embeddings tip, a GPU warning) and the message interpolated the caller's whole
76
+ * argv, every distinct search query minted a brand-new permanent Sentry issue
77
+ * (HQ-CLI 7677702704 — the same unbounded-fingerprint failure fixed for
78
+ * HQ-CLI-S). This typed subclass lets the boundary classify the condition as the
79
+ * caller's environment and stop fingerprinting on the query text.
80
+ */
81
+ export class QmdTerminatedError extends QmdExitError {
82
+ signal;
83
+ name = 'QmdTerminatedError';
84
+ constructor(message, args, status, stdout, stderr, signal) {
85
+ super(message, args, status, stdout, stderr);
86
+ this.signal = signal;
87
+ }
88
+ }
64
89
  function isExecutable(candidate) {
65
90
  try {
66
91
  fs.accessSync(candidate, fs.constants.X_OK);
@@ -231,6 +256,9 @@ const defaultSpawn = (cmd, args, options) => {
231
256
  stdout: result.stdout ?? undefined,
232
257
  stderr: result.stderr ?? undefined,
233
258
  error: result.error,
259
+ // Carry the signal that killed the child (status null) so finishRunQmd can
260
+ // classify a signalled qmd instead of erasing the cause (HQ-CLI 7677702704).
261
+ signal: result.signal,
234
262
  };
235
263
  };
236
264
  /**
@@ -644,6 +672,32 @@ export function resolveQmdInvocation(options = {}) {
644
672
  return bundled;
645
673
  throw new QmdBinaryMissingError(`Unable to resolve qmd. Probed ${probes.join('; ')}. Install @tobilu/qmd or set HQ_QMD_BIN to an executable qmd binary.`);
646
674
  }
675
+ /** ANSI CSI escape sequences qmd may leave on its streams (SGR colour, a trailing show-cursor control). */
676
+ const ANSI_ESCAPE = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
677
+ /** Lines qmd prints as ADVISORIES (a tip, a warning) — never a failure reason. */
678
+ const QMD_ADVISORY_LINE = /^(?:Tip:|Warning:|QMD Warning:)/i;
679
+ /**
680
+ * Render qmd's captured output as a human diagnostic for a SIGNALLED run: strip
681
+ * ANSI/control noise, then drop purely-advisory lines as long as a substantive
682
+ * line survives. If ONLY advisory lines were printed — the reported shape, where
683
+ * an embeddings tip and a GPU warning were all qmd emitted before it was killed —
684
+ * say so explicitly rather than presenting an advisory as the cause of death.
685
+ * Any retained qmd text is redacted + length-bounded, so a hostile line can
686
+ * neither forge a second `hq:` line, emit terminal escapes, nor leak a token.
687
+ */
688
+ function describeQmdTermination(stdout, stderr) {
689
+ const lines = `${stderr}\n${stdout}`
690
+ .split(/\r?\n/)
691
+ .map((line) => line.replace(ANSI_ESCAPE, '').trim())
692
+ .filter((line) => line.length > 0);
693
+ if (lines.length === 0)
694
+ return '';
695
+ const substantive = lines.filter((line) => !QMD_ADVISORY_LINE.test(line));
696
+ if (substantive.length === 0)
697
+ return 'qmd printed only advisory output before it was terminated';
698
+ const detail = redactErrorText(substantive.join(' '));
699
+ return detail ? `qmd reported: ${detail}` : '';
700
+ }
647
701
  /** Normalise a spawn result and raise the typed qmd failures. */
648
702
  function finishRunQmd(result, bin, args) {
649
703
  const normalized = {
@@ -651,12 +705,29 @@ function finishRunQmd(result, bin, args) {
651
705
  stdout: result.stdout ?? '',
652
706
  stderr: result.stderr ?? '',
653
707
  error: result.error,
708
+ signal: result.signal,
654
709
  };
655
710
  if (normalized.error) {
656
711
  throw new QmdBinaryMissingError(`Unable to execute qmd at ${bin}: ${normalized.error.message}`);
657
712
  }
658
713
  if (normalized.status === 0)
659
714
  return normalized;
715
+ // qmd did not EXIT — it was KILLED BY A SIGNAL before it finished (spawnSync:
716
+ // status null, signal set). hq-cli passes qmd no timeout and never signals the
717
+ // child, so the signal came from OUTSIDE hq-cli — most often the host OOM
718
+ // killer during CPU-only embedding on a small box, or an operator interrupt.
719
+ // Name the signal and the SUBCOMMAND only, never args.join(' '), so the Sentry
720
+ // group can no longer fingerprint per search query (HQ-CLI 7677702704).
721
+ if (normalized.status === null && normalized.signal) {
722
+ const signal = String(normalized.signal);
723
+ const subcommand = typeof args[0] === 'string' && args[0].length > 0 ? redactErrorText(args[0]) || 'qmd' : 'qmd';
724
+ const namedSignal = redactErrorText(signal) || 'an unknown signal';
725
+ const context = describeQmdTermination(normalized.stdout, normalized.stderr);
726
+ const signalMessage = context
727
+ ? `qmd ${subcommand} was terminated by signal ${namedSignal} before it finished (${context})`
728
+ : `qmd ${subcommand} was terminated by signal ${namedSignal} before it finished`;
729
+ throw new QmdTerminatedError(signalMessage, args, normalized.status, normalized.stdout, normalized.stderr, signal);
730
+ }
660
731
  const detail = normalized.stderr || normalized.stdout || 'qmd returned no diagnostic output';
661
732
  const message = `qmd ${args.join(' ')} exited with ${normalized.status ?? 'an unknown status'}: ${detail}`;
662
733
  if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(detail)) {
package/dist/main.js CHANGED
@@ -67,6 +67,7 @@ import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
67
67
  import { networkTransportErrorMessage } from "./utils/network-transport-error.js";
68
68
  import { qmdNativeBindingErrorMessage } from "./utils/qmd-native-binding-error.js";
69
69
  import { qmdMissingCollectionMessage } from "./utils/qmd-collection-missing-error.js";
70
+ import { qmdTerminatedMessage } from "./utils/qmd-terminated-error.js";
70
71
  import { isExpectedUserError } from "./utils/expected-cli-error.js";
71
72
  import { isEpipe } from "./utils/epipe.js";
72
73
  import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
@@ -457,7 +458,20 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
457
458
  // raised by hq's OWN reconciliation (`collection add`/`context add`) names
458
459
  // a collection hq built itself, so it stays a captured internal error.
459
460
  const collectionMsg = qmdMsg ? null : qmdMissingCollectionMessage(err);
460
- const envMsg = qmdMsg || collectionMsg ? null : environmentalFsErrorMessage(err);
461
+ // A qmd child KILLED BY A SIGNAL (spawnSync status null + signal) is the
462
+ // caller's HOST, not an hq-cli defect: hq-cli passes qmd no timeout and
463
+ // never signals the child, so the signal came from outside — most often the
464
+ // OOM killer during CPU-only embedding on a small box, or an operator
465
+ // interrupt. Before this branch the discarded signal rendered as `exited
466
+ // with an unknown status: <qmd's raw advisory stderr>`, and because that
467
+ // message embedded the caller's whole argv, each distinct query minted a
468
+ // brand-new permanent issue (HQ-CLI 7677702704, the HQ-CLI-S failure mode).
469
+ // finishRunQmd now types it QmdTerminatedError; print the signal-named
470
+ // remedy and skip capture. Evaluated AFTER the native-binding and
471
+ // collection-missing checks (both narrower typed signatures) and BEFORE the
472
+ // environmental / transport / generic branches.
473
+ const terminatedMsg = qmdMsg || collectionMsg ? null : qmdTerminatedMessage(err);
474
+ const envMsg = qmdMsg || collectionMsg || terminatedMsg ? null : environmentalFsErrorMessage(err);
461
475
  // A raw network transport failure (undici's `TypeError: fetch failed`
462
476
  // with a ConnectTimeoutError / ECONNREFUSED / ENOTFOUND cause) is the
463
477
  // caller's connectivity, not an hq-cli defect. Before this branch it fell
@@ -468,13 +482,16 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
468
482
  // message that names the unreachable host, exit 1, and skip Sentry.
469
483
  // Ordered after the environmental check so a full disk keeps its exact
470
484
  // existing message.
471
- const transportMsg = qmdMsg || collectionMsg || envMsg ? null : networkTransportErrorMessage(err);
485
+ const transportMsg = qmdMsg || collectionMsg || terminatedMsg || envMsg ? null : networkTransportErrorMessage(err);
472
486
  if (qmdMsg) {
473
487
  deps.stderr.write(`hq: ${qmdMsg}\n`);
474
488
  }
475
489
  else if (collectionMsg) {
476
490
  deps.stderr.write(`hq: ${collectionMsg}\n`);
477
491
  }
492
+ else if (terminatedMsg) {
493
+ deps.stderr.write(`hq: ${terminatedMsg}\n`);
494
+ }
478
495
  else if (envMsg) {
479
496
  deps.stderr.write(`hq: ${envMsg}\n`);
480
497
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * If `err` is a qmd child terminated by a genuinely EXTERNAL signal (the host
3
+ * OOM killer or an operator interrupt), return an actionable, signal-named
4
+ * remedy tailored to what qmd was doing; otherwise return `null`. A null result
5
+ * covers non-QmdTerminatedError values AND crash-class/unrecognised signals,
6
+ * which must stay on the captured-error path. Mirrors qmdNativeBindingErrorMessage
7
+ * / qmdMissingCollectionMessage so the top-level handler branches the same way:
8
+ * non-null means print-and-skip-Sentry, null means "handle as usual (capture)".
9
+ */
10
+ export declare function qmdTerminatedMessage(err: unknown): string | null;
11
+ //# sourceMappingURL=qmd-terminated-error.d.ts.map
@@ -0,0 +1,95 @@
1
+ // src/utils/qmd-terminated-error.ts
2
+ //
3
+ // Classify a qmd child that was KILLED BY A SIGNAL — not one that exited
4
+ // non-zero — and decide whether it is the caller's HOST ENVIRONMENT (print an
5
+ // actionable remedy, SKIP Sentry) or a genuine qmd/native CRASH (leave it on the
6
+ // captured-error path). Sibling of qmd-native-binding-error.ts (HQ-CLI-J),
7
+ // qmd-collection-missing-error.ts (HQ-CLI-S), environmental-error.ts (HQ-CLI-2)
8
+ // and network-transport-error.ts (HQ-CLI-G).
9
+ //
10
+ // HQ-CLI 7677702704: `hq search --mode semantic ...` spawned qmd, which was
11
+ // KILLED BY A SIGNAL before it finished. The reported profile — a 2 GB / 2-vCPU
12
+ // box, qmd warning it had no GPU and was building embeddings on CPU, ~12 minutes
13
+ // of runtime before the throw — is the shape of an out-of-memory kill (the Linux
14
+ // OOM killer sends SIGKILL). The spawn seam discarded spawnSync's `signal`, so
15
+ // the failure rendered as `exited with an unknown status: <qmd's raw advisory
16
+ // stderr>`; because that synthesized message interpolated the caller's whole
17
+ // argv, every distinct query minted a brand-new permanent issue — the same
18
+ // unbounded-fingerprint failure fixed for HQ-CLI-S. finishRunQmd now types the
19
+ // condition QmdTerminatedError; this classifier closes the boundary.
20
+ //
21
+ // Which signals are the caller's environment (and skip Sentry):
22
+ // - SIGKILL: the process cannot catch it, so it is always sent from OUTSIDE —
23
+ // almost always the host OOM killer here.
24
+ // - SIGINT / SIGTERM / SIGHUP / SIGQUIT: an operator or a terminated shell
25
+ // interrupting the run.
26
+ // CRASH-class signals — SIGSEGV / SIGABRT / SIGBUS — are deliberately NOT
27
+ // carved out: they usually mean qmd's OWN native code faulted (a genuine
28
+ // bundled-qmd defect), so a swallow would hide a real regression AND mislead the
29
+ // user with a memory remedy. For those (and any unrecognised or missing signal)
30
+ // this returns null so the boundary reports them to Sentry. That stays safe:
31
+ // finishRunQmd's message names the signal and subcommand ONLY (never the query),
32
+ // so a captured crash still groups per signal, not per query.
33
+ //
34
+ // The gate is the TYPED CLASS (`name === 'QmdTerminatedError'`) plus a signal
35
+ // allow-list — never qmd's advisory wording ('Tip:', 'needs embeddings'), which
36
+ // this fix's reproduction shows also rides a SUCCESSFUL exit-0 run. hq-cli passes
37
+ // qmd no timeout and never signals the child, so a carved-out signal always
38
+ // originates outside hq-cli. The only value rendered into the remedy is the
39
+ // signal name, drawn from the allow-list of safe constants and passed through
40
+ // redactErrorText defensively; the caller's query and the raw subcommand are
41
+ // never echoed.
42
+ import { redactErrorText } from "./redact-error-text.js";
43
+ /** SIGKILL: uncatchable, so always sent from outside — here, the host OOM killer. */
44
+ const HOST_KILL = "SIGKILL";
45
+ /** Signals that mean an operator or shell INTERRUPTED qmd before it finished. */
46
+ const INTERRUPTS = new Set(["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"]);
47
+ /** qmd subcommands that are caller-supplied SEARCH reads (mirrors search.ts). */
48
+ const SEARCH_READS = new Set(["search", "vsearch", "query", "get"]);
49
+ /**
50
+ * If `err` is a qmd child terminated by a genuinely EXTERNAL signal (the host
51
+ * OOM killer or an operator interrupt), return an actionable, signal-named
52
+ * remedy tailored to what qmd was doing; otherwise return `null`. A null result
53
+ * covers non-QmdTerminatedError values AND crash-class/unrecognised signals,
54
+ * which must stay on the captured-error path. Mirrors qmdNativeBindingErrorMessage
55
+ * / qmdMissingCollectionMessage so the top-level handler branches the same way:
56
+ * non-null means print-and-skip-Sentry, null means "handle as usual (capture)".
57
+ */
58
+ export function qmdTerminatedMessage(err) {
59
+ if (err === null || typeof err !== "object")
60
+ return null;
61
+ const record = err;
62
+ if (record.name !== "QmdTerminatedError")
63
+ return null;
64
+ const rawSignal = typeof record.signal === "string" ? record.signal : "";
65
+ const isHostKill = rawSignal === HOST_KILL;
66
+ const isInterrupt = INTERRUPTS.has(rawSignal);
67
+ // Crash-class (SIGSEGV/SIGABRT/SIGBUS) and any unrecognised/missing signal are
68
+ // left to the captured-error path — they are likely a real qmd/native defect.
69
+ if (!isHostKill && !isInterrupt)
70
+ return null;
71
+ // rawSignal is now one of a fixed allow-list, so it is safe to name; redact
72
+ // defensively to match the sibling classifiers' discipline.
73
+ const signal = redactErrorText(rawSignal) || "an unknown signal";
74
+ if (isInterrupt) {
75
+ return (`qmd was interrupted (signal ${signal}) before it finished, so the command ` +
76
+ `returned no results. Re-run it when the machine is free.`);
77
+ }
78
+ // SIGKILL: the host reclaimed qmd — most often the OOM killer during CPU-only
79
+ // embedding on a small box. Tailor the remedy to what qmd was doing so an
80
+ // index-build kill is not told to re-run the very command that was killed or
81
+ // offered a search-only `--mode keyword` that does not apply to it.
82
+ const args = record.args;
83
+ const subcommand = Array.isArray(args) && typeof args[0] === "string" ? args[0] : "";
84
+ if (SEARCH_READS.has(subcommand)) {
85
+ return (`The local search was stopped by the host (signal ${signal}) before qmd finished — ` +
86
+ `most often the machine ran out of memory while building embeddings on CPU. Run ` +
87
+ `'hq index sync' once (ideally on a host with more memory) to build embeddings up ` +
88
+ `front, retry with '--mode keyword' (which needs far less memory), or run the ` +
89
+ `search on a host with more free memory.`);
90
+ }
91
+ return (`qmd was stopped by the host (signal ${signal}) before it finished — most often the ` +
92
+ `machine ran out of memory (building a large index on CPU is memory-heavy). Re-run it ` +
93
+ `on a host with more free memory, or index fewer collections at a time.`);
94
+ }
95
+ //# sourceMappingURL=qmd-terminated-error.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.103.17",
3
+ "version": "5.103.19",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  "dependencies": {
31
31
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
32
32
  "@aws-sdk/client-s3": "^3.1049.0",
33
- "@indigoai-us/hq-cloud": "~6.15.29",
33
+ "@indigoai-us/hq-cloud": "~6.15.30",
34
34
  "@indigoai-us/hq-onboarding": "^0.1.0",
35
35
  "@sentry/node": "^10.49.0",
36
36
  "@tobilu/qmd": "2.5.3",