@indigoai-us/hq-cli 5.338.0 → 5.340.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.340.0] — 2026-10-03
6
+ - Preserve resolver output when child processes exit before Windows pipe data has drained.
7
+ - Native `hq core dm-bind` on Windows now finds an `hq.exe` on PATH, and no longer accepts an `hq.cmd` shim it cannot launch.
8
+ - Document the flag-gated HQ Anywhere global install and hqd daemon. The runtime flag remains off by default.
9
+
10
+ ## [5.339.0] — 2026-10-03
11
+ - Reduce cold-start imports for the `checkpoint-stop-gate` and `session-journal` native fast routes.
12
+
5
13
  ## [5.338.0] — 2026-10-03
6
14
  - Preserve policy-trigger mutual exclusion without `flock` and honor configured stale-run thresholds without `yq`.
7
15
  - Add a manual macOS VM parity workflow for checking HQ CLI behavior in Tart.
package/README.md CHANGED
@@ -7,6 +7,11 @@ npm install -g @indigoai-us/hq-cli
7
7
  hq --help
8
8
  ```
9
9
 
10
+ The global HQ runtime installs hooks for supported agent runtimes. When the
11
+ `hq-anywhere-runtime` flag is enabled, the hooks use hqd and a per-user folder
12
+ registry. The flag is off by default. See [HQ Anywhere runtime](docs/anywhere-runtime.md)
13
+ for install and link commands.
14
+
10
15
  ## Deploy comments
11
16
 
12
17
  List comments left on an app in your current hq-deploy organization. Use the
@@ -142,16 +142,60 @@ const command = process.argv[1];
142
142
  if (!command) process.exit(127);
143
143
  const child = spawn(command, process.argv.slice(2), { detached: true, stdio: ["ignore", "pipe", "ignore"] });
144
144
  let finished = false;
145
+ let exited = false;
146
+ let exitStatus = 1;
147
+ let flushedAfterExit = false;
148
+ let pendingWrites = 0;
149
+ let stdoutEnded = false;
150
+ let drainTimer;
151
+ const finishWhenReady = () => {
152
+ if (finished || !exited || pendingWrites !== 0) return;
153
+ if (!stdoutEnded && !flushedAfterExit) return;
154
+ finished = true;
155
+ clearTimeout(timer);
156
+ clearTimeout(drainTimer);
157
+ try { process.kill(-child.pid, "SIGTERM"); } catch (_) {}
158
+ process.exit(exitStatus);
159
+ };
160
+ const forward = (chunk) => {
161
+ pendingWrites++;
162
+ process.stdout.write(chunk, () => {
163
+ pendingWrites--;
164
+ finishWhenReady();
165
+ });
166
+ };
145
167
  const finish = (status) => {
146
168
  if (finished) return;
147
169
  finished = true;
148
170
  clearTimeout(timer);
171
+ clearTimeout(drainTimer);
149
172
  try { process.kill(-child.pid, "SIGTERM"); } catch (_) {}
150
173
  process.exit(status);
151
174
  };
152
- child.stdout.on("data", (chunk) => process.stdout.write(chunk));
175
+ child.stdout.on("data", forward);
176
+ child.stdout.once("end", () => {
177
+ stdoutEnded = true;
178
+ finishWhenReady();
179
+ });
153
180
  child.once("error", () => finish(127));
154
- child.once("exit", (code) => finish(typeof code === "number" ? code : 1));
181
+ child.once("exit", (code) => {
182
+ exited = true;
183
+ exitStatus = typeof code === "number" ? code : 1;
184
+ try { process.kill(-child.pid, "SIGTERM"); } catch (_) {}
185
+ // Normally EOF follows child exit once the pipe has drained. A detached
186
+ // grandchild can keep the pipe open, so bound that wait and flush buffered
187
+ // bytes after a short drain window.
188
+ drainTimer = setTimeout(() => {
189
+ child.stdout.pause();
190
+ let chunk;
191
+ while ((chunk = child.stdout.read()) !== null) forward(chunk);
192
+ process.stdout.write("", () => {
193
+ flushedAfterExit = true;
194
+ finishWhenReady();
195
+ });
196
+ }, 250);
197
+ finishWhenReady();
198
+ });
155
199
  const timer = setTimeout(() => finish(124), 2000);
156
200
  ' "$@"
157
201
  }
@@ -11,6 +11,8 @@ export type DmBindDeps = {
11
11
  home: string;
12
12
  now: () => Date;
13
13
  fs: typeof fs;
14
+ /** Defaults to process.platform; tests pass it to exercise the Windows lookup. */
15
+ platform?: NodeJS.Platform;
14
16
  runHq: (args: string[], env: NodeJS.ProcessEnv) => {
15
17
  code: number;
16
18
  stdout: string;
@@ -91,12 +91,20 @@ function hasHqOnPath(d) {
91
91
  for (const directory of (d.env.PATH ?? "").split(path.delimiter)) {
92
92
  if (!directory)
93
93
  continue;
94
- try {
95
- const stat = d.fs.statSync(path.join(directory, "hq"));
96
- if (stat.isFile() && (stat.mode & 0o111) !== 0)
97
- return true;
94
+ // runHq spawns "hq" without a shell. On Windows that resolves only .exe and
95
+ // .com files, and Windows file modes carry no execute bits, so accept exactly
96
+ // what the spawn can launch. npm's hq.cmd shim needs a shell and is not
97
+ // accepted here.
98
+ const windows = (d.platform ?? process.platform) === "win32";
99
+ const names = windows ? ["hq.exe", "hq.com"] : ["hq"];
100
+ for (const name of names) {
101
+ try {
102
+ const stat = d.fs.statSync(path.join(directory, name));
103
+ if (stat.isFile() && (windows || (stat.mode & 0o111) !== 0))
104
+ return true;
105
+ }
106
+ catch { /* PATH entry is absent */ }
98
107
  }
99
- catch { /* PATH entry is absent */ }
100
108
  }
101
109
  return false;
102
110
  }
@@ -1,4 +1,7 @@
1
- import { type SessionHelperRuntimeOverrides } from "./core-session-helper-runtime.js";
1
+ type SessionHelperRuntimeOverrides = {
2
+ env?: NodeJS.ProcessEnv;
3
+ now?: () => Date;
4
+ };
2
5
  export type SessionJournalOperationResult = {
3
6
  code: number;
4
7
  stdout: Buffer;
@@ -8,4 +11,5 @@ export type SessionJournalOperationResult = {
8
11
  export declare function runSessionJournalOperation(args: readonly string[], _stdin: string, cwd: string, hqRoot: string | undefined, dependencies?: SessionHelperRuntimeOverrides): SessionJournalOperationResult;
9
12
  /** Native implementation of the fail-soft session journal helper. */
10
13
  export declare function runSessionJournalFast(args: readonly string[], stdin: string, cwd: string, hqRoot: string | undefined, dependencies?: SessionHelperRuntimeOverrides): number;
14
+ export {};
11
15
  //# sourceMappingURL=core-session-journal-native.d.ts.map
@@ -1,7 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { resolveSessionHelperRuntime } from "./core-session-helper-runtime.js";
4
- import { resolveBundledAsset } from "../utils/hq-roots-core.js";
3
+ import { fileURLToPath } from "node:url";
5
4
  const USAGE = `session-journal.sh: usage:
6
5
  session-journal.sh write "<title>" [--files f1,f2,...] [--body-file PATH] [--session KEY]
7
6
  session-journal.sh list [--date YYYY-MM-DD]
@@ -56,6 +55,7 @@ function yamlDoubleQuoted(value) {
56
55
  }
57
56
  const warning = (message) => { process.stderr.write(`session-journal: ${message}\n`); };
58
57
  const journalDir = (root, date) => path.join(root, "workspace/threads/journal", date);
58
+ const sessionJournalAsset = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../assets/scaffold/core/scripts/session-journal.sh");
59
59
  const hasFile = (file) => {
60
60
  try {
61
61
  return fs.statSync(file).isFile();
@@ -190,11 +190,13 @@ function runToolCounter(root, action, sessionArg, env, now, writers) {
190
190
  const counter = path.join(dir, `.tool-count-${key}`);
191
191
  const lock = `${counter}.lock`;
192
192
  const reportDirectoryWriteFailure = (error, line) => {
193
- const code = error.code;
194
- if (code !== "EISDIR")
193
+ if (error.code !== "EISDIR")
195
194
  return;
196
- const asset = resolveBundledAsset("core/scripts/session-journal.sh");
197
- writers.stderr(`${asset}: line ${line}: ${counter}: Is a directory\n`);
195
+ if (!fs.existsSync(sessionJournalAsset)) {
196
+ throw new Error("Bundled asset is missing from the installed package: assets/scaffold/core/scripts/session-journal.sh\n" +
197
+ "This is a packaging fault — the file exists in source but was not published.");
198
+ }
199
+ writers.stderr(`${sessionJournalAsset}: line ${line}: ${counter}: Is a directory\n`);
198
200
  };
199
201
  if (action === "increment") {
200
202
  withCounterLock(lock, () => {
@@ -243,7 +245,10 @@ export function runSessionJournalFast(args, stdin, cwd, hqRoot, dependencies = {
243
245
  }
244
246
  function executeSessionJournal(args, cwd, hqRoot, dependencies, writers) {
245
247
  const warning = (message) => { writers.stderr(`session-journal: ${message}\n`); };
246
- const runtime = resolveSessionHelperRuntime(dependencies);
248
+ const runtime = {
249
+ env: dependencies.env ?? process.env,
250
+ now: dependencies.now ?? (() => new Date()),
251
+ };
247
252
  const today = () => runtime.now().toISOString().slice(0, 10);
248
253
  const nowIso = () => runtime.now().toISOString().replace(/\.\d{3}Z$/, "Z");
249
254
  const nowHhmm = () => runtime.now().toISOString().slice(11, 16) + "Z";
@@ -14,4 +14,10 @@ export declare function readStdinIfPiped(stream: NodeJS.ReadStream): Promise<str
14
14
  * doing anything.
15
15
  */
16
16
  export declare function runFastCore(argv: readonly string[], parsed?: ReturnType<typeof parseFastCore>): Promise<void>;
17
+ /**
18
+ * Resolve valid explicit roots for the two measured hook routes without loading
19
+ * the general root resolver. Anything uncertain falls through to that resolver,
20
+ * which retains its diagnostics and cwd-discovery behavior.
21
+ */
22
+ export declare function resolveExplicitFastLiveRoot(hqRootArg: string | undefined, env: NodeJS.ProcessEnv): string | undefined;
17
23
  //# sourceMappingURL=fast-core.d.ts.map
package/dist/fast-core.js CHANGED
@@ -6,7 +6,14 @@
6
6
  * entries use the bundled-script runner; native entries import their one handler
7
7
  * module. The registration in `core.ts` remains the complete fallback path.
8
8
  */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { fileURLToPath } from "node:url";
9
12
  import { parseFastCore } from "./fast-scaffold.js";
13
+ const FAST_ROUTE_PROFILE_TOKEN = "hq-fast-route";
14
+ function wantsFastRouteProfile(env) {
15
+ return env.NODE_DEBUG === FAST_ROUTE_PROFILE_TOKEN;
16
+ }
10
17
  export async function readStdinIfPiped(stream) {
11
18
  if (stream.isTTY)
12
19
  return "";
@@ -23,6 +30,14 @@ export async function readStdinIfPiped(stream) {
23
30
  export async function runFastCore(argv, parsed = parseFastCore(argv)) {
24
31
  if (!parsed)
25
32
  return;
33
+ const profile = wantsFastRouteProfile(process.env);
34
+ const profileStart = profile ? process.hrtime.bigint() : undefined;
35
+ let familyFlagMs = 0;
36
+ let rootResolutionMs = 0;
37
+ let handlerImportMs = 0;
38
+ let stdinReadMs = 0;
39
+ const profileMark = () => profile ? process.hrtime.bigint() : undefined;
40
+ const profileElapsed = (start, end) => start === undefined || end === undefined ? 0 : Number(end - start) / 1_000_000;
26
41
  const entry = parsed.entry;
27
42
  // The full graph's process gate accepts textual true/false overrides. It ignores
28
43
  // numeric env values, so preserve that behavior for family flags here too.
@@ -36,6 +51,7 @@ export async function runFastCore(argv, parsed = parseFastCore(argv)) {
36
51
  await runCli();
37
52
  return;
38
53
  }
54
+ familyFlagMs = profileElapsed(profileStart, profileMark());
39
55
  const useShell = entry.kind === "native" && Boolean(entry.familyFlagKey) && !familyEnabled;
40
56
  let worktreeScopeFixEnabled;
41
57
  if (entry.name === "repo-run-registry") {
@@ -53,21 +69,32 @@ export async function runFastCore(argv, parsed = parseFastCore(argv)) {
53
69
  // fail-soft behavior; newly routed commands fall back to the full graph so
54
70
  // its expected-user-error formatting remains authoritative.
55
71
  let hqRoot;
72
+ const rootResolutionStartedAt = profileMark();
56
73
  if (shouldInjectRoot) {
57
- const { resolveLiveRoot } = await import("./utils/hq-roots-core.js");
58
- try {
59
- hqRoot = resolveLiveRoot(parsed.hqRootArg ? { hqRoot: parsed.hqRootArg } : {});
74
+ const shortcutRoot = entry.name === "checkpoint-stop-gate" || entry.name === "session-journal"
75
+ ? resolveExplicitFastLiveRoot(parsed.hqRootArg, process.env)
76
+ : undefined;
77
+ if (shortcutRoot !== undefined) {
78
+ hqRoot = shortcutRoot;
60
79
  }
61
- catch {
62
- if (entry.kind === "native") {
63
- const { runCli } = await import("./main.js");
64
- await runCli();
65
- return;
80
+ else {
81
+ const { resolveLiveRoot } = await import("./utils/hq-roots-core.js");
82
+ try {
83
+ hqRoot = resolveLiveRoot(parsed.hqRootArg ? { hqRoot: parsed.hqRootArg } : {});
84
+ }
85
+ catch {
86
+ if (entry.kind === "native") {
87
+ const { runCli } = await import("./main.js");
88
+ await runCli();
89
+ return;
90
+ }
91
+ hqRoot = undefined;
66
92
  }
67
- hqRoot = undefined;
68
93
  }
69
94
  }
95
+ rootResolutionMs = profileElapsed(rootResolutionStartedAt, profileMark());
70
96
  if (entry.kind === "native" && !useShell) {
97
+ const handlerImportStartedAt = profileMark();
71
98
  const { installFastPathStreamErrorHandlers } = await import("./utils/fast-stream-errors.js");
72
99
  installFastPathStreamErrorHandlers();
73
100
  const handlerModule = await import(entry.modulePath);
@@ -75,8 +102,21 @@ export async function runFastCore(argv, parsed = parseFastCore(argv)) {
75
102
  if (typeof handler !== "function") {
76
103
  throw new Error(`Native fast handler ${entry.exportName} was not exported`);
77
104
  }
105
+ handlerImportMs = profileElapsed(handlerImportStartedAt, profileMark());
106
+ const stdinStartedAt = profileMark();
78
107
  const readsStdin = entry.readsStdinFor?.(parsed.args) ?? entry.readsStdin ?? false;
79
108
  const stdin = readsStdin ? await readStdinIfPiped(process.stdin) : "";
109
+ stdinReadMs = profileElapsed(stdinStartedAt, profileMark());
110
+ if (profile && profileStart !== undefined) {
111
+ process.stderr.write(`HQ_FAST_ROUTE_PROFILE ${JSON.stringify({
112
+ command: entry.name,
113
+ pre_handler_ms: profileElapsed(profileStart, profileMark()),
114
+ family_flag_ms: familyFlagMs,
115
+ root_resolution_ms: rootResolutionMs,
116
+ handler_import_ms: handlerImportMs,
117
+ stdin_read_ms: stdinReadMs,
118
+ })}\n`);
119
+ }
80
120
  const code = await handler(parsed.args, stdin, process.cwd(), hqRoot, entry.name.split(" ").at(-1) ?? entry.name, parsed.hqRootArg, worktreeScopeFixEnabled);
81
121
  if (code === undefined) {
82
122
  if (!entry.fallbackToCommandGraph) {
@@ -120,6 +160,29 @@ export async function runFastCore(argv, parsed = parseFastCore(argv)) {
120
160
  // Propagate verbatim: these scripts use their exit code as their interface.
121
161
  process.exit(code);
122
162
  }
163
+ /**
164
+ * Resolve valid explicit roots for the two measured hook routes without loading
165
+ * the general root resolver. Anything uncertain falls through to that resolver,
166
+ * which retains its diagnostics and cwd-discovery behavior.
167
+ */
168
+ export function resolveExplicitFastLiveRoot(hqRootArg, env) {
169
+ const candidate = hqRootArg || env.HQ_ROOT || env.CLAUDE_PROJECT_DIR;
170
+ if (!candidate)
171
+ return undefined;
172
+ try {
173
+ const resolved = fs.realpathSync(candidate);
174
+ const packageRoot = fs.realpathSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
175
+ const relative = path.relative(packageRoot, resolved);
176
+ if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)))
177
+ return undefined;
178
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory())
179
+ return undefined;
180
+ return resolved;
181
+ }
182
+ catch {
183
+ return undefined;
184
+ }
185
+ }
123
186
  /** Match hq-flags-client's literal true/false env override without loading its module. */
124
187
  function localBooleanFlagOverride(flagKey, env) {
125
188
  const envKey = `HQ_FLAG_${flagKey.replace(/[.-]/g, "_").toUpperCase()}`;
@@ -0,0 +1,60 @@
1
+ # HQ Anywhere runtime
2
+
3
+ HQ Anywhere installs global hooks for supported agent runtimes and uses the
4
+ local hqd daemon on macOS and Linux. Windows does not currently start hqd, so
5
+ the daemon-backed routing described here is unavailable there. A per-user
6
+ registry links repositories to HQ companies. The `hq-anywhere-runtime` flag
7
+ controls these features and is off by default.
8
+
9
+ ## Install
10
+
11
+ Install and start hqd before registering global hooks. The global installer
12
+ checks for the daemon service when choosing the hook target.
13
+
14
+ ```sh
15
+ hq daemon install
16
+ hq daemon status
17
+ hq install --global --runtime claude
18
+ ```
19
+
20
+ `hq install --global` defaults to Claude. Select other supported runtimes
21
+ explicitly with `--runtime codex`, `--runtime chatgpt`, or `--runtime grok`.
22
+ Claude and Codex receive local hook configuration. ChatGPT and Grok print
23
+ connector setup instructions and require the runtime flag to be enabled.
24
+
25
+ The CLI stores the HQ root at `~/.hq/root` and keeps the folder registry in
26
+ `~/.hq/registry.json`. These files belong to the current host. They are not
27
+ stored in a repository or synced HQ folder.
28
+
29
+ On Windows, `hq daemon install` reports that hqd cannot start because its
30
+ named-pipe ACL is not supported. Global hook installation may still configure
31
+ local runtime hooks, but it does not provide the hqd routing described below.
32
+
33
+ When the flag is enabled, the global hook uses hqd for events outside the HQ
34
+ root. Inside the root, the project hook routes through hqd when it is running
35
+ and uses the direct master hook when it is stopped.
36
+
37
+ ## Link a repository
38
+
39
+ Run `hq link <company>` from a repository to associate it with a company. The
40
+ registry uses the normalized Git remote when one is available. Otherwise, it
41
+ uses the repository's canonical absolute path.
42
+
43
+ `HQ_REGISTRY_DIR` changes the registry directory, and `HQ_HQD_SOCKET` changes
44
+ the daemon socket path. The managed service does not inherit overrides from
45
+ the shell that ran `hq daemon install`; it persists `PATH` and
46
+ `HQ_DAEMON_DIR`. Keep the defaults for a managed install unless you configure
47
+ both the daemon service and the agent runtime with the same overrides. For a
48
+ systemd user service, add `Environment=HQ_REGISTRY_DIR=...` and
49
+ `Environment=HQ_HQD_SOCKET=...` in `systemctl --user edit hq-daemon.service`,
50
+ then run `systemctl --user daemon-reload` and
51
+ `systemctl --user restart hq-daemon.service`. For launchd, set both keys in the
52
+ `EnvironmentVariables` dictionary in
53
+ `~/Library/LaunchAgents/ai.getindigo.hq-daemon.plist`, then restart the job
54
+ with `launchctl kickstart -k gui/$(id -u)/ai.getindigo.hq-daemon`. Also set
55
+ both variables in the agent runtime environment so hooks and `hq link` use
56
+ the same paths.
57
+
58
+ The registry does not infer a company when a repository has no matching entry.
59
+ That session uses personal context. The socket protocol and registry format are
60
+ documented in the HQ Anywhere runtime knowledge entry in hq-core.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.338.0",
3
+ "version": "5.340.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "assets/lanes-reconcile",
17
17
  "assets/lanes-hooks",
18
18
  "templates",
19
+ "docs/anywhere-runtime.md",
19
20
  "CHANGELOG.md",
20
21
  "README.md",
21
22
  "contracts",