@indigoai-us/hq-cli 5.345.135 → 5.345.137

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/assets/scaffold/core/scripts/worktree-gc.sh +98 -8
  3. package/dist/command-catalog.generated.d.ts +17 -6
  4. package/dist/command-catalog.generated.js +23 -7
  5. package/dist/commands/core-checkpoint-native.d.ts +10 -0
  6. package/dist/commands/core-checkpoint-native.js +60 -15
  7. package/dist/commands/daemon.js +3 -1
  8. package/dist/commands/integrations-core.d.ts +22 -0
  9. package/dist/commands/integrations-core.js +25 -0
  10. package/dist/commands/integrations-manage.d.ts +24 -1
  11. package/dist/commands/integrations-manage.js +92 -17
  12. package/dist/commands/integrations.d.ts +11 -0
  13. package/dist/commands/integrations.js +100 -19
  14. package/dist/commands/lanes-loop.d.ts +9 -0
  15. package/dist/commands/lanes-loop.js +9 -1
  16. package/dist/commands/lanes.js +69 -54
  17. package/dist/commands/meetings.d.ts +2 -0
  18. package/dist/commands/meetings.js +57 -14
  19. package/dist/commands/mesh.d.ts +19 -1
  20. package/dist/commands/mesh.js +158 -70
  21. package/dist/commands/reindex.js +80 -5
  22. package/dist/commands/storage-offload.d.ts +93 -28
  23. package/dist/commands/storage-offload.js +290 -34
  24. package/dist/commands/storage.js +60 -7
  25. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2550 -1748
  26. package/dist/hq-cloud-sync-runner/package.json +1 -1
  27. package/dist/index.js +4 -0
  28. package/dist/lib/capacity/route.js +24 -0
  29. package/dist/lib/core-utils/session-company.d.ts +11 -0
  30. package/dist/lib/core-utils/session-company.js +34 -0
  31. package/dist/lib/core-utils/worktree-resume.js +44 -4
  32. package/dist/lib/core-utils/worktree-session-owner.d.ts +27 -0
  33. package/dist/lib/core-utils/worktree-session-owner.js +157 -0
  34. package/dist/lib/daemon/catalog.d.ts +2 -0
  35. package/dist/lib/daemon/catalog.js +3 -3
  36. package/dist/lib/daemon/hq-child-bootstrap-stage.d.ts +5 -0
  37. package/dist/lib/daemon/hq-child-bootstrap-stage.js +3 -11
  38. package/dist/lib/daemon/server.js +10 -1
  39. package/dist/lib/daemon/supervisor.js +6 -0
  40. package/dist/lib/daemon/sync-runner-bundle.d.ts +5 -0
  41. package/dist/lib/daemon/sync-runner-bundle.js +4 -12
  42. package/dist/lib/doctor/checks/claude-wiring.js +2 -1
  43. package/dist/lib/doctor/fix/apply.d.ts +20 -0
  44. package/dist/lib/doctor/fix/apply.js +63 -3
  45. package/dist/lib/doctor/fix/remediation.d.ts +1 -1
  46. package/dist/lib/doctor/fix/remediation.js +16 -0
  47. package/dist/lib/doctor/shared.d.ts +11 -1
  48. package/dist/lib/doctor/shared.js +16 -2
  49. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  50. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  51. package/dist/lib/lanes/monitor.js +14 -2
  52. package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
  53. package/dist/lib/mesh/live/daemon/install.js +22 -0
  54. package/dist/lib/mesh/live/flush.js +106 -3
  55. package/dist/lib/mesh/live/session-events-client.d.ts +9 -0
  56. package/dist/lib/mesh/live/session-events-client.js +15 -0
  57. package/dist/lib/work-context/state.d.ts +10 -0
  58. package/dist/lib/work-context/state.js +5 -0
  59. package/dist/utils/fsync-directory.d.ts +17 -0
  60. package/dist/utils/fsync-directory.js +27 -0
  61. package/dist/utils/windows-hide-default.d.ts +42 -0
  62. package/dist/utils/windows-hide-default.js +98 -0
  63. package/package.json +2 -2
@@ -354,12 +354,24 @@ const WATCH_RUNTIME_EXECUTABLES = ["bun", "deno", "node", "nodejs", "ts-node", "
354
354
  // on the raw line, then validate whatever precedes it as an hq invocation.
355
355
  // A path form (absolute or ./, ../) is required so an unrelated leading
356
356
  // command such as "vim /tmp/hq" is not absorbed into the path.
357
+ //
358
+ // Package managers that install the bin through a shell shim (pnpm, some
359
+ // volta/yarn setups) exec the runtime with the package entrypoint instead of
360
+ // the hq symlink, so the ps line reads
361
+ // "node .../node_modules/@indigoai-us/hq-cli/dist/index.js lanes watch ...".
362
+ // Accept that entrypoint path as an hq executable too.
363
+ const HQ_CLI_ENTRYPOINT_SUFFIX = "/hq-cli/dist/index.js";
357
364
  function isHqExecutablePath(candidate) {
358
365
  if (candidate === "hq")
359
366
  return true;
360
- if (!/^\.{0,2}\//u.test(candidate) || path.basename(candidate) !== "hq") {
367
+ if (!/^\.{0,2}\//u.test(candidate))
368
+ return false;
369
+ const isHqBin = path.basename(candidate) === "hq";
370
+ const isHqEntrypoint = candidate
371
+ .replace(/\\/gu, "/")
372
+ .endsWith(HQ_CLI_ENTRYPOINT_SUFFIX);
373
+ if (!isHqBin && !isHqEntrypoint)
361
374
  return false;
362
- }
363
375
  return !candidate.split(" ").some((piece) => piece.startsWith("-"));
364
376
  }
365
377
  function isHqInvocationPrefix(prefix) {
@@ -121,6 +121,18 @@ export declare function detectPlatform(platform?: NodeJS.Platform): DaemonInstal
121
121
  * binary when both exist.
122
122
  */
123
123
  export declare function preferStableHqBinary(entry: string, platform?: DaemonInstallPlatform, existsSync?: typeof fs.existsSync): string;
124
+ /**
125
+ * A path that `node <path>` can run. preferStableHqBinary maps the package
126
+ * entry to the npm `hq.cmd` shim on Windows, which is right for a task that
127
+ * launches through cmd.exe but wrong for every caller that runs
128
+ * `node <hqBinary>`: node parses the batch file as JavaScript and exits with
129
+ * "SyntaxError: Invalid or unexpected token" on `@ECHO off`
130
+ * (feedback_a60339e8, feedback_516e4427). Map the shim back to the package's
131
+ * JS entry, which sits beside it in the same npm prefix and so stays valid
132
+ * across `npm i -g` updates. Anything that is not a Windows command shim is
133
+ * returned unchanged.
134
+ */
135
+ export declare function nodeRunnableHqEntry(hqBinary: string, platform?: DaemonInstallPlatform | NodeJS.Platform, existsSync?: typeof fs.existsSync): string;
124
136
  /**
125
137
  * Absolute path of the CLI entry that is executing this process.
126
138
  * realpath of argv[1] only — never a PATH lookup of `hq`.
@@ -299,6 +299,28 @@ export function preferStableHqBinary(entry, platform = detectPlatform(), existsS
299
299
  }
300
300
  return entry;
301
301
  }
302
+ /** JS entry of the hq-cli package, relative to an npm global prefix. */
303
+ const HQ_CLI_PACKAGE_ENTRY = ["node_modules", "@indigoai-us", "hq-cli", "dist", "index.js"];
304
+ /**
305
+ * A path that `node <path>` can run. preferStableHqBinary maps the package
306
+ * entry to the npm `hq.cmd` shim on Windows, which is right for a task that
307
+ * launches through cmd.exe but wrong for every caller that runs
308
+ * `node <hqBinary>`: node parses the batch file as JavaScript and exits with
309
+ * "SyntaxError: Invalid or unexpected token" on `@ECHO off`
310
+ * (feedback_a60339e8, feedback_516e4427). Map the shim back to the package's
311
+ * JS entry, which sits beside it in the same npm prefix and so stays valid
312
+ * across `npm i -g` updates. Anything that is not a Windows command shim is
313
+ * returned unchanged.
314
+ */
315
+ export function nodeRunnableHqEntry(hqBinary, platform = detectPlatform(), existsSync = fs.existsSync) {
316
+ if (platform !== "win32")
317
+ return hqBinary;
318
+ const ext = path.win32.extname(hqBinary).toLowerCase();
319
+ if (ext !== ".cmd" && ext !== ".bat" && ext !== ".ps1")
320
+ return hqBinary;
321
+ const entry = path.win32.join(path.win32.dirname(hqBinary), ...HQ_CLI_PACKAGE_ENTRY);
322
+ return existsSync(entry) ? entry : hqBinary;
323
+ }
302
324
  /**
303
325
  * Absolute path of the CLI entry that is executing this process.
304
326
  * realpath of argv[1] only — never a PATH lookup of `hq`.
@@ -22,7 +22,7 @@ import { defaultSleep, FLUSH_MAX_ATTEMPTS, fullJitterDelayMs, } from "./backoff.
22
22
  import { LOCAL_ONLY_FIELDS, stripLocalOnlyFields } from "./format-spool-line.js";
23
23
  import { enrichSessionEndEvent } from "./report-summary.js";
24
24
  import { createWorkLocationResolver, withWorkLocation, } from "./work-location.js";
25
- import { SESSION_EVENTS_BATCH_MAX, WRITABLE_CONTEXT_STATUSES, parseSessionEventsBatchAck, } from "./session-events-client.js";
25
+ import { SESSION_EVENTS_BATCH_MAX, WRITABLE_CONTEXT_STATUSES, isSessionOwnerMismatch, parseSessionEventsBatchAck, SESSION_OWNER_MISMATCH, } from "./session-events-client.js";
26
26
  import { appendDeadLetterLine, appendDroppedReceipt, appendHeldLinesIfAbsent, appendSpoolLine, claimHeld, claimSpool, countDistinctHeldEvents, countJsonlLines, recoverOrphanClaims, removeClaimFile, } from "./spool.js";
27
27
  import { workMeshHeldPath, workMeshRoot } from "./paths.js";
28
28
  /** Bound a replayed session backlog so one flush cannot burst hundreds of events. */
@@ -306,7 +306,7 @@ export async function flushSessionEvents(inputDeps) {
306
306
  v: 1,
307
307
  sessionId,
308
308
  dropped: remainingItems.length,
309
- reason: "untracked",
309
+ reason: decision.reason,
310
310
  at: now().toISOString(),
311
311
  }, deps.workMeshRoot);
312
312
  summary.dropped += remainingItems.length;
@@ -353,6 +353,22 @@ export async function flushSessionEvents(inputDeps) {
353
353
  accountBatchSuccess(result.body, originalChunk, deps.workMeshRoot, now, summary, markDisposed);
354
354
  continue;
355
355
  }
356
+ if (!result.retryable && isSessionOwnerMismatch(result.status, result.body)) {
357
+ // Another actor already reports this session (feedback_5b4683c2).
358
+ // Not an error on this machine: drop the session's remaining events
359
+ // with a receipt, remember the answer, and skip it from now on.
360
+ const unsent = remainingItems.filter((item) => pendingRestore.has(item.event));
361
+ dropOwnerMismatchedSession({
362
+ sessionId,
363
+ companyUid: decision.companyUid,
364
+ items: unsent,
365
+ deps,
366
+ now,
367
+ summary,
368
+ markDisposed,
369
+ });
370
+ continue sessionLoop;
371
+ }
356
372
  if (!result.retryable && result.status === 404) {
357
373
  const rejectedProjectId = decision.projectId;
358
374
  updateSessionState(sessionId, deps.workContextRoot, (current) => {
@@ -560,15 +576,34 @@ function accountBatchSuccess(body, originalChunk, meshRoot, now, summary, markDi
560
576
  }
561
577
  const rejectedIds = new Set();
562
578
  const at = now().toISOString();
579
+ let ownerMismatchDropped = 0;
563
580
  for (const row of ack.rejected) {
564
581
  const item = byId.get(row.eventId);
565
582
  if (!item)
566
583
  continue;
584
+ if (row.code === SESSION_OWNER_MISMATCH) {
585
+ ownerMismatchDropped += 1;
586
+ summary.dropped += 1;
587
+ rejectedIds.add(row.eventId);
588
+ markDisposed(item.event);
589
+ continue;
590
+ }
567
591
  appendDeadLetterLine(deadLetterEnvelope(item.event, row.code, row.code, at, row.code), meshRoot);
568
592
  summary.deadLettered += 1;
569
593
  rejectedIds.add(row.eventId);
570
594
  markDisposed(item.event);
571
595
  }
596
+ if (ownerMismatchDropped > 0) {
597
+ const sessionId = sessionIdOf(originalChunk[0].event);
598
+ appendDroppedReceipt({
599
+ v: 1,
600
+ sessionId,
601
+ dropped: ownerMismatchDropped,
602
+ reason: "owner_mismatch",
603
+ code: SESSION_OWNER_MISMATCH,
604
+ at,
605
+ }, meshRoot);
606
+ }
572
607
  const remaining = originalChunk.filter((item) => {
573
608
  const id = eventIdOf(item.event);
574
609
  return !id || !rejectedIds.has(id);
@@ -767,6 +802,26 @@ async function postPresenceLeaveIfNeeded(input) {
767
802
  });
768
803
  if (!result.ok) {
769
804
  const at = input.now().toISOString();
805
+ if (!result.retryable && isSessionOwnerMismatch(result.status, result.body)) {
806
+ // The old company says another actor owns this session, so there is no
807
+ // presence of ours to leave. Drop the leave events, clear the pending
808
+ // leave, and let the remaining events continue to the new binding.
809
+ dropOwnerMismatchedSession({
810
+ sessionId: input.sessionId,
811
+ companyUid: leave.companyUid,
812
+ items: leaveItems,
813
+ deps: input.deps,
814
+ now: input.now,
815
+ summary: input.summary,
816
+ markDisposed: input.markDisposed,
817
+ clearPresenceLeave: true,
818
+ });
819
+ return {
820
+ state: readSessionState(input.sessionId, input.deps.workContextRoot) ?? input.state,
821
+ items: remaining,
822
+ abort: false,
823
+ };
824
+ }
770
825
  if (!result.retryable) {
771
826
  for (const item of input.items) {
772
827
  appendDeadLetterLine(deadLetterEnvelope(item.event, `HTTP_${result.status}`, result.status, at), input.deps.workMeshRoot);
@@ -843,12 +898,60 @@ function rebindUnboundSession(sessionId, items, state, deps, nowMs) {
843
898
  }
844
899
  return readSessionState(sessionId, deps.workContextRoot) ?? state;
845
900
  }
901
+ const loggedOwnerMismatch = new Set();
902
+ /**
903
+ * Drop a session's events after the server answered 403
904
+ * SESSION_OWNER_MISMATCH: write one dropped receipt, mark the session state so
905
+ * later flushes skip it without posting, and log once per session. These
906
+ * events never reach dead-letter, so daemon health stays clean.
907
+ */
908
+ function dropOwnerMismatchedSession(input) {
909
+ const at = input.now().toISOString();
910
+ if (input.items.length > 0) {
911
+ appendDroppedReceipt({
912
+ v: 1,
913
+ sessionId: input.sessionId,
914
+ dropped: input.items.length,
915
+ reason: "owner_mismatch",
916
+ code: SESSION_OWNER_MISMATCH,
917
+ companyUid: input.companyUid,
918
+ at,
919
+ }, input.deps.workMeshRoot);
920
+ }
921
+ input.summary.dropped += input.items.length;
922
+ for (const item of input.items)
923
+ input.markDisposed(item.event);
924
+ updateSessionState(input.sessionId, input.deps.workContextRoot, (current) => {
925
+ if (!current)
926
+ return current;
927
+ if (input.clearPresenceLeave) {
928
+ const { presenceLeave: _leave, ...rest } = current;
929
+ return { ...rest, updatedAt: at };
930
+ }
931
+ return {
932
+ ...current,
933
+ ownerMismatch: { companyUid: input.companyUid, at },
934
+ updatedAt: at,
935
+ };
936
+ });
937
+ const key = `${input.deps.workContextRoot}\0${input.sessionId}`;
938
+ if (!loggedOwnerMismatch.has(key)) {
939
+ loggedOwnerMismatch.add(key);
940
+ input.deps.log?.(`work-mesh: session ${input.sessionId} is reported by another actor (403 SESSION_OWNER_MISMATCH); dropped ${input.items.length} event(s), not dead-lettered`);
941
+ }
942
+ }
846
943
  function classifySession(state) {
847
944
  if (!state) {
848
945
  return { action: "hold", heldReason: "STATE_ABSENT" };
849
946
  }
850
947
  if (state.contextStatus === "untracked") {
851
- return { action: "drop" };
948
+ return { action: "drop", reason: "untracked" };
949
+ }
950
+ // The server already told us another actor owns this session in this
951
+ // company; posting again would only earn another 403.
952
+ if (state.ownerMismatch?.companyUid &&
953
+ state.companyUid === state.ownerMismatch.companyUid) {
954
+ return { action: "drop", reason: "owner_mismatch" };
852
955
  }
853
956
  if (state.contextStatus === "needs_company") {
854
957
  return { action: "hold", heldReason: "NEEDS_COMPANY" };
@@ -41,6 +41,15 @@ export interface SessionEventsBatchAck {
41
41
  */
42
42
  export declare function parseSessionEventsBatchAck(body: unknown): SessionEventsBatchAck | null;
43
43
  export type SessionEventsPoster = (body: SessionEventsBatchBody) => Promise<SessionEventsPostResult>;
44
+ /**
45
+ * Server code for a session that another actor in the same company already
46
+ * reports (for example a local bot's own headless turn, picked up by the
47
+ * person's daemon). The server will never accept this actor's events for that
48
+ * session, so the flush drops them with a receipt instead of dead-lettering.
49
+ */
50
+ export declare const SESSION_OWNER_MISMATCH = "SESSION_OWNER_MISMATCH";
51
+ /** True for the 403 SESSION_OWNER_MISMATCH answer (feedback_5b4683c2). */
52
+ export declare function isSessionOwnerMismatch(status: number, body: unknown): boolean;
44
53
  export declare function classifySessionEventsResponse(status: number, networkError?: boolean): Pick<SessionEventsPostResult, "ok" | "retryable" | "networkError">;
45
54
  /**
46
55
  * Build a poster that uses vaultApiFetch + Cognito token.
@@ -65,6 +65,21 @@ export function parseSessionEventsBatchAck(body) {
65
65
  }
66
66
  return { accepted, duplicates, rejected };
67
67
  }
68
+ /**
69
+ * Server code for a session that another actor in the same company already
70
+ * reports (for example a local bot's own headless turn, picked up by the
71
+ * person's daemon). The server will never accept this actor's events for that
72
+ * session, so the flush drops them with a receipt instead of dead-lettering.
73
+ */
74
+ export const SESSION_OWNER_MISMATCH = "SESSION_OWNER_MISMATCH";
75
+ /** True for the 403 SESSION_OWNER_MISMATCH answer (feedback_5b4683c2). */
76
+ export function isSessionOwnerMismatch(status, body) {
77
+ if (status !== 403)
78
+ return false;
79
+ if (!body || typeof body !== "object" || Array.isArray(body))
80
+ return false;
81
+ return body.code === SESSION_OWNER_MISMATCH;
82
+ }
68
83
  export function classifySessionEventsResponse(status, networkError = false) {
69
84
  if (networkError || status === 0) {
70
85
  return { ok: false, retryable: true, networkError: true };
@@ -117,6 +117,16 @@ export interface SessionStateFile {
117
117
  taskId?: string;
118
118
  bindingEpisodeId?: string;
119
119
  };
120
+ /**
121
+ * LOCAL-ONLY. Set when the server answered 403 SESSION_OWNER_MISMATCH for
122
+ * this session in `companyUid`: another actor already reports it (a local
123
+ * bot's own turn, a checkpoint helper). While the session stays bound to
124
+ * that company, flush drops its events locally instead of posting them.
125
+ */
126
+ ownerMismatch?: {
127
+ companyUid: string;
128
+ at: string;
129
+ };
120
130
  updatedAt: string;
121
131
  }
122
132
  /**
@@ -215,6 +215,8 @@ function projectSessionState(state) {
215
215
  out.projectHint = state.projectHint;
216
216
  if (state.presenceLeave?.companyUid)
217
217
  out.presenceLeave = state.presenceLeave;
218
+ if (state.ownerMismatch?.companyUid)
219
+ out.ownerMismatch = state.ownerMismatch;
218
220
  return out;
219
221
  }
220
222
  /**
@@ -282,6 +284,9 @@ export function mergeLocalSessionFields(next, prior, options = {}) {
282
284
  if (!out.presenceLeave && prior.presenceLeave) {
283
285
  out.presenceLeave = prior.presenceLeave;
284
286
  }
287
+ if (!out.ownerMismatch && prior.ownerMismatch) {
288
+ out.ownerMismatch = prior.ownerMismatch;
289
+ }
285
290
  if (out.projectId)
286
291
  out.projectHint = undefined;
287
292
  const sameBinding = prior.companyUid === out.companyUid &&
@@ -0,0 +1,17 @@
1
+ /** Filesystem seam for {@link fsyncDirectory} tests. */
2
+ export interface DirectorySyncIo {
3
+ openSync: (file: string, flags: number) => number;
4
+ fsyncSync: (fd: number) => void;
5
+ closeSync: (fd: number) => void;
6
+ }
7
+ /**
8
+ * Flush a directory entry (a rename or a new child) to disk.
9
+ *
10
+ * Windows has no directory fsync: FlushFileBuffers on a directory handle
11
+ * fails, and Node reports `EPERM: operation not permitted, fsync`. NTFS
12
+ * journals the rename itself, so there is nothing to flush and the call is
13
+ * skipped on win32 (feedback_516e4427). On every other platform a failure
14
+ * still throws, so a POSIX stage keeps its durability check.
15
+ */
16
+ export declare function fsyncDirectory(directory: string, platform?: NodeJS.Platform, io?: DirectorySyncIo): void;
17
+ //# sourceMappingURL=fsync-directory.d.ts.map
@@ -0,0 +1,27 @@
1
+ import * as fs from "node:fs";
2
+ const defaultIo = {
3
+ openSync: (file, flags) => fs.openSync(file, flags),
4
+ fsyncSync: (fd) => fs.fsyncSync(fd),
5
+ closeSync: (fd) => fs.closeSync(fd),
6
+ };
7
+ /**
8
+ * Flush a directory entry (a rename or a new child) to disk.
9
+ *
10
+ * Windows has no directory fsync: FlushFileBuffers on a directory handle
11
+ * fails, and Node reports `EPERM: operation not permitted, fsync`. NTFS
12
+ * journals the rename itself, so there is nothing to flush and the call is
13
+ * skipped on win32 (feedback_516e4427). On every other platform a failure
14
+ * still throws, so a POSIX stage keeps its durability check.
15
+ */
16
+ export function fsyncDirectory(directory, platform = process.platform, io = defaultIo) {
17
+ if (platform === "win32")
18
+ return;
19
+ const fd = io.openSync(directory, fs.constants.O_RDONLY);
20
+ try {
21
+ io.fsyncSync(fd);
22
+ }
23
+ finally {
24
+ io.closeSync(fd);
25
+ }
26
+ }
27
+ //# sourceMappingURL=fsync-directory.js.map
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Default `windowsHide: true` for child processes of a background hq on Windows.
3
+ *
4
+ * A console program (node, bash, git, cygpath, claude --version) started from
5
+ * a process that has no console of its own gets a new, visible console
6
+ * window. The HQ desktop app, the sync runner and hooks start hq with piped
7
+ * stdio and no console, so every child hq spawned flashed a window on screen,
8
+ * every minute or two, all day (feedback_351a40d5). Node only hides it when a
9
+ * call passes `windowsHide: true`, and hq-cli has a few hundred spawn sites.
10
+ *
11
+ * When this process is on Windows and none of its standard streams is a
12
+ * terminal, every child_process call that does not choose `windowsHide`
13
+ * itself gets `windowsHide: true`. An interactive hq (any standard stream is
14
+ * a TTY) is left alone, so a child that shares the user's console still does.
15
+ */
16
+ type ChildProcessModule = typeof import("node:child_process");
17
+ /** child_process entry points whose options object accepts `windowsHide`. */
18
+ export declare const WINDOWS_HIDE_FUNCTIONS: readonly ["spawn", "spawnSync", "execFile", "execFileSync", "exec", "execSync", "fork"];
19
+ export type WindowsHideFunction = (typeof WINDOWS_HIDE_FUNCTIONS)[number];
20
+ /**
21
+ * Return `args` with `windowsHide: true` in the options object of a
22
+ * child_process call, unless the caller set `windowsHide` either way. The
23
+ * input array and the caller's options object are not mutated.
24
+ */
25
+ export declare function applyWindowsHideDefault(fn: WindowsHideFunction, args: readonly unknown[]): unknown[];
26
+ /** True when none of stdin, stdout and stderr is a terminal. */
27
+ export declare function isBackgroundProcess(isTty?: (fd: number) => boolean): boolean;
28
+ export interface InstallWindowsHideDefaultOptions {
29
+ platform?: NodeJS.Platform;
30
+ background?: boolean;
31
+ /** child_process module to patch (tests pass a stand-in). */
32
+ childProcess?: ChildProcessModule;
33
+ /** Called after patching so ESM named imports see the wrapped functions. */
34
+ syncExports?: () => void;
35
+ }
36
+ /**
37
+ * Patch node:child_process once for this process. Returns true when the
38
+ * default is (now or already) in force.
39
+ */
40
+ export declare function installWindowsHideDefault(options?: InstallWindowsHideDefaultOptions): boolean;
41
+ export {};
42
+ //# sourceMappingURL=windows-hide-default.d.ts.map
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Default `windowsHide: true` for child processes of a background hq on Windows.
3
+ *
4
+ * A console program (node, bash, git, cygpath, claude --version) started from
5
+ * a process that has no console of its own gets a new, visible console
6
+ * window. The HQ desktop app, the sync runner and hooks start hq with piped
7
+ * stdio and no console, so every child hq spawned flashed a window on screen,
8
+ * every minute or two, all day (feedback_351a40d5). Node only hides it when a
9
+ * call passes `windowsHide: true`, and hq-cli has a few hundred spawn sites.
10
+ *
11
+ * When this process is on Windows and none of its standard streams is a
12
+ * terminal, every child_process call that does not choose `windowsHide`
13
+ * itself gets `windowsHide: true`. An interactive hq (any standard stream is
14
+ * a TTY) is left alone, so a child that shares the user's console still does.
15
+ */
16
+ // Only node:module is imported at load time (index.ts already loads it). This
17
+ // module is imported on every hq start, including the hook-time fast routes,
18
+ // so node:tty and node:util are loaded lazily and only on Windows.
19
+ import { createRequire, syncBuiltinESMExports } from "node:module";
20
+ /** child_process entry points whose options object accepts `windowsHide`. */
21
+ export const WINDOWS_HIDE_FUNCTIONS = ["spawn", "spawnSync", "execFile", "execFileSync", "exec", "execSync", "fork"];
22
+ const INSTALLED = Symbol.for("hq.windowsHideDefault.installed");
23
+ /** Same symbol as util.promisify.custom (a registered symbol). */
24
+ const PROMISIFY_CUSTOM = Symbol.for("nodejs.util.promisify.custom");
25
+ const requireBuiltin = createRequire(import.meta.url);
26
+ function defaultIsatty(fd) {
27
+ return requireBuiltin("node:tty").isatty(fd);
28
+ }
29
+ function isOptionsObject(value) {
30
+ return typeof value === "object" && value !== null && !Array.isArray(value);
31
+ }
32
+ /**
33
+ * Return `args` with `windowsHide: true` in the options object of a
34
+ * child_process call, unless the caller set `windowsHide` either way. The
35
+ * input array and the caller's options object are not mutated.
36
+ */
37
+ export function applyWindowsHideDefault(fn, args) {
38
+ const out = [...args];
39
+ let index;
40
+ if (fn === "exec" || fn === "execSync") {
41
+ index = 1;
42
+ }
43
+ else {
44
+ // (file, args?, options?, callback?): the args array is optional and may
45
+ // be passed as undefined/null ahead of an options object.
46
+ index = Array.isArray(out[1]) || (out[1] == null && out.length > 2) ? 2 : 1;
47
+ }
48
+ const current = out[index];
49
+ if (isOptionsObject(current)) {
50
+ if ("windowsHide" in current)
51
+ return out;
52
+ out[index] = { ...current, windowsHide: true };
53
+ }
54
+ else if (current == null) {
55
+ out[index] = { windowsHide: true };
56
+ }
57
+ else if (typeof current === "function") {
58
+ out.splice(index, 0, { windowsHide: true });
59
+ }
60
+ return out;
61
+ }
62
+ /** True when none of stdin, stdout and stderr is a terminal. */
63
+ export function isBackgroundProcess(isTty = defaultIsatty) {
64
+ return !isTty(0) && !isTty(1) && !isTty(2);
65
+ }
66
+ /**
67
+ * Patch node:child_process once for this process. Returns true when the
68
+ * default is (now or already) in force.
69
+ */
70
+ export function installWindowsHideDefault(options = {}) {
71
+ const platform = options.platform ?? process.platform;
72
+ if (platform !== "win32")
73
+ return false;
74
+ if (!(options.background ?? isBackgroundProcess()))
75
+ return false;
76
+ const childProcess = options.childProcess ?? requireBuiltin("node:child_process");
77
+ const target = childProcess;
78
+ if (target[INSTALLED])
79
+ return true;
80
+ for (const name of WINDOWS_HIDE_FUNCTIONS) {
81
+ const original = target[name];
82
+ if (typeof original !== "function")
83
+ continue;
84
+ const originalFn = original;
85
+ const wrapped = function (...args) {
86
+ return originalFn.apply(this, applyWindowsHideDefault(name, args));
87
+ };
88
+ const custom = originalFn[PROMISIFY_CUSTOM];
89
+ if (typeof custom === "function") {
90
+ wrapped[PROMISIFY_CUSTOM] = (...args) => custom(...applyWindowsHideDefault(name, args));
91
+ }
92
+ target[name] = wrapped;
93
+ }
94
+ target[INSTALLED] = true;
95
+ (options.syncExports ?? syncBuiltinESMExports)();
96
+ return true;
97
+ }
98
+ //# sourceMappingURL=windows-hide-default.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.135",
3
+ "version": "5.345.137",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -48,7 +48,7 @@
48
48
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
49
49
  "@aws-sdk/client-s3": "^3.1049.0",
50
50
  "@aws-sdk/dsql-signer": "^3.1146.0",
51
- "@indigoai-us/hq-cloud": "~6.18.71",
51
+ "@indigoai-us/hq-cloud": "~6.18.76",
52
52
  "@indigoai-us/hq-flags-client": "^0.1.2",
53
53
  "@modelcontextprotocol/sdk": "1.29.0",
54
54
  "@sentry/node": "^10.49.0",