@indigoai-us/hq-cli 5.345.91 → 5.345.92

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,9 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.92] — 2026-10-12
6
+ - The background search-index worker no longer spins at full CPU when its working directory is deleted: it only writes to absolute log paths, rejects a `C:/...` log path on macOS and Linux, receives the launcher's HQ root, and kills any qmd cleanup, update, or embed step that runs past 2 hours (`QMD_REINDEX_STEP_TIMEOUT_SEC` overrides). The bundled `qmd-reindex-bg.sh` converts paths with `cygpath` only on Git Bash, MSYS2, or Cygwin.
7
+
5
8
  ## [5.345.91] — 2026-10-12
6
9
  - Add published E2E checks comparing native commands with bundled shell scripts for qmd background reindex, hq-session, checkpoint stop gate, and worker skill commands.
7
10
 
@@ -37,15 +37,28 @@ if ! command -v hq >/dev/null 2>&1; then
37
37
  exit 127
38
38
  fi
39
39
 
40
- if command -v cygpath >/dev/null 2>&1; then
41
- HQ_ROOT="$(cygpath -m "$HQ_ROOT")"
40
+ # Convert paths to Windows-native form ONLY on a Windows Bash (Git Bash, MSYS2,
41
+ # Cygwin). A `cygpath` merely being on PATH is not evidence of Windows: a test
42
+ # that stubs it on macOS turned `--log /tmp/qmd-handoff.log` into the RELATIVE
43
+ # POSIX path `C:/tmp/qmd-handoff.log`, and the detached worker that inherited
44
+ # it spun at 100% CPU for days once its temp cwd was deleted.
45
+ convert_windows_paths=0
46
+ case "${OSTYPE:-}" in
47
+ msys* | cygwin*) convert_windows_paths=1 ;;
48
+ esac
49
+ case "$(uname -s 2>/dev/null || true)" in
50
+ MINGW* | MSYS* | CYGWIN*) convert_windows_paths=1 ;;
51
+ esac
52
+ if [ "$convert_windows_paths" -eq 1 ] && ! command -v cygpath >/dev/null 2>&1; then
53
+ convert_windows_paths=0
42
54
  fi
43
- if command -v cygpath >/dev/null 2>&1; then
55
+
56
+ if [ "$convert_windows_paths" -eq 1 ]; then
44
57
  HQ_ROOT="$(cygpath -m "$HQ_ROOT")"
45
58
  fi
46
59
  forwarded_args=("$@")
47
60
  forwarded_arg_count=$#
48
- if command -v cygpath >/dev/null 2>&1; then
61
+ if [ "$convert_windows_paths" -eq 1 ]; then
49
62
  for ((index = 0; index < forwarded_arg_count; index++)); do
50
63
  case "${forwarded_args[index]}" in
51
64
  --log) index=$((index + 1)); if ((index < forwarded_arg_count)); then forwarded_args[index]="$(cygpath -m "${forwarded_args[index]}")"; fi ;;
@@ -19,10 +19,10 @@ export type BackgroundDependencies = {
19
19
  /** Apply the per-document size cap to qmd's config before the update reads anything. */
20
20
  applyIndexSizeLimit: (hqRoot: string) => unknown;
21
21
  runQmd: (args: string[], options?: RunQmdOptions) => QmdProcessResult;
22
- spawnWorker: (options: {
23
- logPath: string;
24
- fallbackLogPath?: string;
25
- }) => number;
22
+ spawnWorker: (options: SpawnWorkerOptions) => number;
23
+ /** Test seams for log-path resolution; production uses the real process. */
24
+ platform?: NodeJS.Platform;
25
+ cwd?: () => string;
26
26
  /** Test seam for simulating a competing owner replacing the atomic record. */
27
27
  afterOwnerPublish?: (ownerFile: string) => void;
28
28
  /** Test seams for signal delivery; production uses the real process. */
@@ -36,6 +36,32 @@ export type BackgroundStatus = {
36
36
  lock: 'held' | 'stale' | 'free';
37
37
  completedAt?: number;
38
38
  };
39
+ export type SpawnWorkerOptions = {
40
+ logPath: string;
41
+ fallbackLogPath?: string;
42
+ /** Absolute HQ root the worker must index; forwarded as --hq-root. */
43
+ hqRoot?: string;
44
+ };
45
+ /**
46
+ * Turn a requested worker log path (or HQ root) into an absolute native path,
47
+ * or undefined when it cannot be used safely.
48
+ *
49
+ * - On a non-Windows host a drive-letter path (`C:/tmp/qmd-handoff.log`) is the
50
+ * residue of a Windows-only `cygpath -m` conversion applied where it does not
51
+ * belong. POSIX treats it as RELATIVE, so honouring it would create a `C:`
52
+ * directory inside whatever the current directory happens to be. Reject it.
53
+ * - A relative path is resolved against `cwd` only when a cwd is supplied (the
54
+ * launcher, whose cwd is the caller's). The detached worker never resolves
55
+ * against its inherited cwd: that directory can be deleted under it (a test
56
+ * sandbox's temp dir), and Node's `fs.mkdirSync(relative, { recursive: true })`
57
+ * then loops forever at 100% CPU instead of failing.
58
+ */
59
+ export declare function resolveNativeAbsolutePath(logPath: string, options?: {
60
+ platform?: NodeJS.Platform;
61
+ cwd?: () => string;
62
+ }): string | undefined;
63
+ /** Per-step qmd timeout for the detached worker (QMD_REINDEX_STEP_TIMEOUT_SEC). */
64
+ export declare function workerStepTimeoutMs(env: NodeJS.ProcessEnv): number;
39
65
  /**
40
66
  * Injectable seams for the launcher's worker-log open and detached spawn. Split
41
67
  * out purely as a test seam so a synthetic open failure can be forced without
@@ -87,10 +113,7 @@ type WorkerLogOpen = {
87
113
  * (appendWorkerLog, capWorkerLog) already swallows I/O failures the same way.
88
114
  */
89
115
  export declare function openWorkerLog(logPath: string, fallbackLogPath: string | undefined, io: Pick<SpawnWorkerIo, 'mkdirSync' | 'openSync'>): WorkerLogOpen;
90
- export declare function defaultSpawnWorker({ logPath, fallbackLogPath }: {
91
- logPath: string;
92
- fallbackLogPath?: string;
93
- }, io?: SpawnWorkerIo): number;
116
+ export declare function defaultSpawnWorker({ logPath, fallbackLogPath, hqRoot }: SpawnWorkerOptions, io?: SpawnWorkerIo): number;
94
117
  /** Defaults used by the CLI; tests supply every nondeterministic dependency. */
95
118
  export declare function defaultBackgroundDependencies(hqRoot: string): BackgroundDependencies;
96
119
  export declare function installWorkerCleanup(cleanup: () => void, processEvents?: Pick<NodeJS.Process, 'once'>, exit?: (code: number) => void): void;
@@ -8,6 +8,54 @@ import { applyIndexSizeLimit as defaultApplyIndexSizeLimit } from './max-doc-byt
8
8
  import { acquireEmbedLock as acquireLock, installEmbedLockCleanup, lockPath, lockRoot, lockState, releaseEmbedLock as releaseLock, } from './embed-lock.js';
9
9
  import { LoadGateConfigurationError, checkEmbedLoad, defaultLoadGateDependencies, recordSuccessfulEmbed, } from './load-gate.js';
10
10
  const COMPLETE_NAME = 'qmd-reindex-bg.completed';
11
+ /**
12
+ * Default hard wall-clock limit for EACH qmd step (cleanup, update, embed) the
13
+ * detached worker runs. The worker has no terminal and nobody waiting on it, so
14
+ * an unbounded step could hold the single-flight lock and a CPU for days. Two
15
+ * hours leaves a first full CPU-only embed of a large HQ room to finish.
16
+ * Override with QMD_REINDEX_STEP_TIMEOUT_SEC (a positive integer).
17
+ */
18
+ const DEFAULT_STEP_TIMEOUT_MS = 2 * 60 * 60 * 1_000;
19
+ const WINDOWS_DRIVE_PATH = /^[A-Za-z]:[\\/]/;
20
+ /**
21
+ * Turn a requested worker log path (or HQ root) into an absolute native path,
22
+ * or undefined when it cannot be used safely.
23
+ *
24
+ * - On a non-Windows host a drive-letter path (`C:/tmp/qmd-handoff.log`) is the
25
+ * residue of a Windows-only `cygpath -m` conversion applied where it does not
26
+ * belong. POSIX treats it as RELATIVE, so honouring it would create a `C:`
27
+ * directory inside whatever the current directory happens to be. Reject it.
28
+ * - A relative path is resolved against `cwd` only when a cwd is supplied (the
29
+ * launcher, whose cwd is the caller's). The detached worker never resolves
30
+ * against its inherited cwd: that directory can be deleted under it (a test
31
+ * sandbox's temp dir), and Node's `fs.mkdirSync(relative, { recursive: true })`
32
+ * then loops forever at 100% CPU instead of failing.
33
+ */
34
+ export function resolveNativeAbsolutePath(logPath, options = {}) {
35
+ const platform = options.platform ?? process.platform;
36
+ const pathApi = platform === 'win32' ? path.win32 : path.posix;
37
+ if (!logPath)
38
+ return undefined;
39
+ if (platform !== 'win32' && WINDOWS_DRIVE_PATH.test(logPath))
40
+ return undefined;
41
+ if (pathApi.isAbsolute(logPath))
42
+ return logPath;
43
+ if (!options.cwd)
44
+ return undefined;
45
+ try {
46
+ return pathApi.resolve(options.cwd(), logPath);
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ /** Per-step qmd timeout for the detached worker (QMD_REINDEX_STEP_TIMEOUT_SEC). */
53
+ export function workerStepTimeoutMs(env) {
54
+ const raw = env.QMD_REINDEX_STEP_TIMEOUT_SEC;
55
+ if (raw && /^\d+$/.test(raw) && Number(raw) > 0)
56
+ return Number(raw) * 1_000;
57
+ return DEFAULT_STEP_TIMEOUT_MS;
58
+ }
11
59
  function errnoInfo(error) {
12
60
  const e = error;
13
61
  return { code: e?.code, syscall: e?.syscall };
@@ -75,7 +123,7 @@ const defaultSpawnWorkerIo = {
75
123
  spawn: (command, args, options) => spawn(command, args, options),
76
124
  report: reportWorkerLogDegradation,
77
125
  };
78
- export function defaultSpawnWorker({ logPath, fallbackLogPath }, io = defaultSpawnWorkerIo) {
126
+ export function defaultSpawnWorker({ logPath, fallbackLogPath, hqRoot }, io = defaultSpawnWorkerIo) {
79
127
  const opened = openWorkerLog(logPath, fallbackLogPath, io);
80
128
  if (opened.error) {
81
129
  io.report({ requested: logPath, used: opened.logPath, usedFallback: opened.usedFallback, ...opened.error });
@@ -91,7 +139,10 @@ export function defaultSpawnWorker({ logPath, fallbackLogPath }, io = defaultSpa
91
139
  const stdio = opened.fd === null
92
140
  ? ['ignore', 'ignore', 'ignore']
93
141
  : ['ignore', opened.fd, opened.fd];
94
- const child = io.spawn(process.execPath, [entry, 'index', 'background', '--worker', ...logArgs], {
142
+ // Forward the launcher's root so the worker indexes what the caller asked for
143
+ // instead of re-deriving a root from its inherited (possibly temporary) cwd.
144
+ const rootArgs = hqRoot ? ['--hq-root', hqRoot] : [];
145
+ const child = io.spawn(process.execPath, [entry, 'index', 'background', '--worker', ...rootArgs, ...logArgs], {
95
146
  detached: true,
96
147
  windowsHide: true,
97
148
  stdio,
@@ -203,6 +254,10 @@ function workerLogPath(env) {
203
254
  function appendWorkerLog(logPath, text) {
204
255
  if (!text)
205
256
  return;
257
+ // Never mkdir a relative path: in a deleted cwd Node's recursive mkdirSync
258
+ // spins forever (see resolveNativeAbsolutePath). Callers pass an absolute path.
259
+ if (!path.isAbsolute(logPath))
260
+ return;
206
261
  try {
207
262
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
208
263
  fs.appendFileSync(logPath, text.endsWith('\n') ? text : `${text}\n`);
@@ -217,6 +272,8 @@ function capWorkerLog(logPath, env) {
217
272
  const max = Number(raw);
218
273
  if (max === 0)
219
274
  return;
275
+ if (!path.isAbsolute(logPath))
276
+ return;
220
277
  try {
221
278
  if (!fs.existsSync(logPath) || fs.statSync(logPath).size <= max)
222
279
  return;
@@ -265,9 +322,26 @@ export function runBackgroundLauncher(dependencies) {
265
322
  // openWorkerLog. `home` is validated non-empty above, so the fallback is
266
323
  // always inside a directory this user owns.
267
324
  const fallbackLogPath = path.join(home, '.hq', 'logs', 'qmd-handoff.log');
325
+ const platform = dependencies.platform ?? process.platform;
326
+ const cwd = dependencies.cwd ?? (() => process.cwd());
327
+ const requestedLogPath = workerLogPath(dependencies.env);
328
+ const logPath = resolveNativeAbsolutePath(requestedLogPath, { platform, cwd });
329
+ if (!logPath) {
330
+ try {
331
+ (dependencies.writeStderr ?? ((text) => process.stderr.write(text)))(`hq: background reindex log ${requestedLogPath} is not a usable absolute path on this platform; writing worker output to ${fallbackLogPath} instead.\n`);
332
+ }
333
+ catch { /* the notice is best-effort and must never crash the launcher */ }
334
+ }
335
+ // Hand the worker an absolute root resolved against the caller's cwd, which
336
+ // still exists now; a drive-letter root on POSIX is dropped like the log path.
337
+ const hqRoot = resolveNativeAbsolutePath(dependencies.hqRoot, { platform, cwd });
268
338
  return {
269
339
  state: 'launched',
270
- pid: dependencies.spawnWorker({ logPath: workerLogPath(dependencies.env), fallbackLogPath }),
340
+ pid: dependencies.spawnWorker({
341
+ logPath: logPath ?? fallbackLogPath,
342
+ fallbackLogPath,
343
+ ...(hqRoot ? { hqRoot } : {}),
344
+ }),
271
345
  };
272
346
  }
273
347
  /** Run the single-flight cleanup → update → embed pipeline in a worker only. */
@@ -290,7 +364,12 @@ export async function runBackgroundWorker(dependencies) {
290
364
  // The cap lives inside the exit cleanup because a signal-path process.exit
291
365
  // never unwinds to a `finally` — the shell capped in its EXIT trap for the
292
366
  // same reason.
293
- const logPath = workerLogPath(dependencies.env);
367
+ // The launcher always forwards an absolute --log. Anything else (a relative or
368
+ // drive-letter path from a direct invocation) goes to the per-user log rather
369
+ // than being resolved against this detached process's inherited cwd.
370
+ const logPath = resolveNativeAbsolutePath(workerLogPath(dependencies.env), { platform: dependencies.platform })
371
+ ?? path.join(home, '.hq', 'logs', 'qmd-handoff.log');
372
+ const timeoutMs = workerStepTimeoutMs(dependencies.env);
294
373
  let released = false;
295
374
  const cleanup = () => {
296
375
  if (released)
@@ -337,7 +416,7 @@ export async function runBackgroundWorker(dependencies) {
337
416
  if (await signalWindow())
338
417
  return { state: 'terminated' };
339
418
  try {
340
- appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['cleanup'], { cwd: dependencies.hqRoot })));
419
+ appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['cleanup'], { cwd: dependencies.hqRoot, timeoutMs })));
341
420
  }
342
421
  catch (error) {
343
422
  appendWorkerLog(logPath, errorOutput(error)); // cleanup is intentionally best-effort
@@ -345,7 +424,7 @@ export async function runBackgroundWorker(dependencies) {
345
424
  if (await signalWindow())
346
425
  return { state: 'terminated' };
347
426
  try {
348
- appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['update'], { cwd: dependencies.hqRoot })));
427
+ appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['update'], { cwd: dependencies.hqRoot, timeoutMs })));
349
428
  }
350
429
  catch (error) {
351
430
  appendWorkerLog(logPath, errorOutput(error));
@@ -376,7 +455,7 @@ export async function runBackgroundWorker(dependencies) {
376
455
  if (loadDecision.forced)
377
456
  (dependencies.writeStderr ?? ((text) => process.stderr.write(text)))(loadDecision.notice);
378
457
  try {
379
- appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['embed'], { cwd: dependencies.hqRoot })));
458
+ appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['embed'], { cwd: dependencies.hqRoot, timeoutMs })));
380
459
  recordSuccessfulEmbed(home, loadGateDependencies);
381
460
  }
382
461
  catch (error) {
@@ -64,6 +64,8 @@ export type QmdSpawn = (cmd: string, args: string[], options: {
64
64
  env?: NodeJS.ProcessEnv;
65
65
  encoding: 'utf8';
66
66
  timeout?: number;
67
+ /** Signal sent when `timeout` elapses (spawnSync default: SIGTERM). */
68
+ killSignal?: NodeJS.Signals;
67
69
  shell?: boolean;
68
70
  }) => {
69
71
  status: number | null;
@@ -160,6 +162,20 @@ export declare class QmdTerminatedError extends QmdExitError {
160
162
  name: string;
161
163
  constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, signal: string);
162
164
  }
165
+ /**
166
+ * qmd ran past the hard wall-clock limit its caller set (`RunQmdOptions.timeoutMs`)
167
+ * and was KILLED by hq-cli. Unlike the parent {@link QmdTerminatedError}, the
168
+ * signal here originates INSIDE hq-cli: spawnSync reports `error.code ===
169
+ * 'ETIMEDOUT'` after delivering `killSignal`. Raised only when the caller opted
170
+ * into a timeout, so every untimed qmd call keeps its existing classification.
171
+ * The background reindex worker uses this to bound each step instead of
172
+ * holding the single-flight lock indefinitely.
173
+ */
174
+ export declare class QmdTimedOutError extends QmdTerminatedError {
175
+ readonly timeoutMs: number;
176
+ name: string;
177
+ constructor(message: string, args: string[], stdout: string, stderr: string, signal: string, timeoutMs: number);
178
+ }
163
179
  /**
164
180
  * qmd refused an LLM-backed operation because its local LLM is DISABLED IN CI:
165
181
  * qmd hard-disables every LLM operation whenever `CI` is truthy in the
@@ -511,6 +527,14 @@ export type RunQmdOptions = {
511
527
  * cwd / interpreter branches are provable without touching the real disk.
512
528
  */
513
529
  exists?: (candidate: string) => boolean;
530
+ /**
531
+ * Hard wall-clock limit for the qmd child, in milliseconds. When it elapses
532
+ * the child is sent SIGKILL and {@link QmdTimedOutError} is thrown. Omitted
533
+ * (the default) means no limit, preserving every interactive caller's
534
+ * behaviour. Applies to the real spawn path only; the legacy `runner` seam
535
+ * has no process to kill.
536
+ */
537
+ timeoutMs?: number;
514
538
  };
515
539
  /** Run qmd with captured output and typed failures. */
516
540
  export declare function runQmd(args: string[], options?: RunQmdOptions): QmdProcessResult;
@@ -133,6 +133,23 @@ export class QmdTerminatedError extends QmdExitError {
133
133
  this.signal = signal;
134
134
  }
135
135
  }
136
+ /**
137
+ * qmd ran past the hard wall-clock limit its caller set (`RunQmdOptions.timeoutMs`)
138
+ * and was KILLED by hq-cli. Unlike the parent {@link QmdTerminatedError}, the
139
+ * signal here originates INSIDE hq-cli: spawnSync reports `error.code ===
140
+ * 'ETIMEDOUT'` after delivering `killSignal`. Raised only when the caller opted
141
+ * into a timeout, so every untimed qmd call keeps its existing classification.
142
+ * The background reindex worker uses this to bound each step instead of
143
+ * holding the single-flight lock indefinitely.
144
+ */
145
+ export class QmdTimedOutError extends QmdTerminatedError {
146
+ timeoutMs;
147
+ name = 'QmdTimedOutError';
148
+ constructor(message, args, stdout, stderr, signal, timeoutMs) {
149
+ super(message, args, null, stdout, stderr, signal);
150
+ this.timeoutMs = timeoutMs;
151
+ }
152
+ }
136
153
  /**
137
154
  * qmd refused an LLM-backed operation because its local LLM is DISABLED IN CI:
138
155
  * qmd hard-disables every LLM operation whenever `CI` is truthy in the
@@ -557,6 +574,7 @@ const defaultSpawn = (cmd, args, options) => {
557
574
  env: options.env,
558
575
  encoding: 'utf8',
559
576
  timeout: options.timeout,
577
+ killSignal: options.killSignal,
560
578
  shell: options.shell,
561
579
  });
562
580
  return {
@@ -588,6 +606,7 @@ function spawnQmd(invocation, args, options = {}) {
588
606
  env,
589
607
  encoding: 'utf8',
590
608
  timeout: options.timeout,
609
+ ...(options.killSignal ? { killSignal: options.killSignal } : {}),
591
610
  shell: plan.shell,
592
611
  });
593
612
  }
@@ -1286,12 +1305,18 @@ export function runQmd(args, options = {}) {
1286
1305
  // above never opens a store).
1287
1306
  ensureQmdStoreDir(options.env ?? process.env, { cwd: options.cwd });
1288
1307
  const launcher = invocationBin(invocation);
1308
+ const timeoutMs = options.timeoutMs !== undefined && options.timeoutMs > 0 ? options.timeoutMs : undefined;
1289
1309
  const result = spawnQmd(invocation, args, {
1290
1310
  cwd: options.cwd,
1291
1311
  env: options.env ?? process.env,
1292
1312
  platform: options.platform,
1293
1313
  spawn: options.spawn,
1314
+ ...(timeoutMs !== undefined ? { timeout: timeoutMs, killSignal: 'SIGKILL' } : {}),
1294
1315
  });
1316
+ if (timeoutMs !== undefined && result.error?.code === 'ETIMEDOUT') {
1317
+ const subcommand = typeof args[0] === 'string' && args[0].length > 0 ? redactErrorText(args[0]) || 'qmd' : 'qmd';
1318
+ throw new QmdTimedOutError(`qmd ${subcommand} exceeded its ${timeoutMs}ms limit and was killed`, args, result.stdout ?? '', result.stderr ?? '', String(result.signal ?? 'SIGKILL'), timeoutMs);
1319
+ }
1295
1320
  // Thread attribution so finishRunQmd can attribute a spawn-level ENOENT to the
1296
1321
  // right component — the working directory, the Node interpreter, or a
1297
1322
  // genuinely missing qmd — instead of blaming the launcher for all three
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.91",
3
+ "version": "5.345.92",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {