@indigoai-us/hq-cli 5.269.0 → 5.270.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,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.270.0] — 2026-09-28
6
+
7
+ ### Fixed
8
+
9
+ - Company billing links in CLI plan-limit notices no longer depend on the
10
+ rollout flag.
11
+
12
+ ### Removed
13
+
14
+ - Removed stale gate language from hire-grant command help. Enforcement remains
15
+ unconditional; backfill remains dry-run only.
16
+
17
+ ### Fixed
18
+
19
+ - `hq files unshare --with` now accepts the same person UID principals as direct grants.
20
+ - The update gate no longer retries failed Windows installs through `sudo -n`.
21
+
5
22
  ## [5.269.0] — 2026-09-28
6
23
 
7
24
  ### Fixed
@@ -334,6 +351,10 @@
334
351
  - Removed the unused `cli.network.force-ipv4` hq-flags lookup and forced IPv4
335
352
  startup path. The existing Node family-attempt timeout remains unchanged.
336
353
  - `hq auth login` continues when it cannot read or save the remembered provider. It prints a dim warning with the state path and error code; a save failure does not turn a successful sign-in into a login failure.
354
+ - `hq doctor`, client health metadata, and feedback attachments now use the
355
+ newest sync-log source from the desktop app or configured daemon directory.
356
+ Daemon `HQ_DAEMON_DIR` overrides and log rotations are supported, and
357
+ numeric runner error levels count in health metadata.
337
358
 
338
359
  ## [5.242.0] — 2026-09-27
339
360
 
@@ -1701,7 +1701,7 @@ export declare const COMMAND_CATALOG: readonly [{
1701
1701
  }];
1702
1702
  readonly options: readonly [{
1703
1703
  readonly flags: "--with <principal>";
1704
- readonly description: "Email address, group id, or '@all' to remove the company-wide grant";
1704
+ readonly description: "Person uid, email address, group id, or '@all' to remove the grant";
1705
1705
  }];
1706
1706
  readonly subcommands: readonly [];
1707
1707
  }, {
@@ -7755,7 +7755,7 @@ export declare const COMMAND_CATALOG: readonly [{
7755
7755
  readonly description: "Non-negative safe integer hire grant";
7756
7756
  }, {
7757
7757
  readonly flags: "--grant-context <base64url>";
7758
- readonly description: "Primary-authorized hire hierarchy and gate decision";
7758
+ readonly description: "Primary-authorized hire hierarchy context";
7759
7759
  }, {
7760
7760
  readonly flags: "--json";
7761
7761
  readonly description: "JSON on stdout (always)";
@@ -7807,7 +7807,7 @@ export declare const COMMAND_CATALOG: readonly [{
7807
7807
  readonly subcommands: readonly [];
7808
7808
  }, {
7809
7809
  readonly name: "backfill-grants";
7810
- readonly description: "Preview or apply missing hire grants for alive manager lanes";
7810
+ readonly description: "Preview missing hire grants for alive manager lanes; writes are disabled";
7811
7811
  readonly aliases: readonly [];
7812
7812
  readonly hidden: false;
7813
7813
  readonly usage: "[options]";
@@ -7817,7 +7817,7 @@ export declare const COMMAND_CATALOG: readonly [{
7817
7817
  readonly description: "Print proposed values without writing (default)";
7818
7818
  }, {
7819
7819
  readonly flags: "--apply";
7820
- readonly description: "Write missing hire grants; only run before enforcement is enabled";
7820
+ readonly description: "Retained for compatibility; writes are disabled";
7821
7821
  }, {
7822
7822
  readonly flags: "--json";
7823
7823
  readonly description: "JSON on stdout (always)";
@@ -2170,7 +2170,7 @@ export const COMMAND_CATALOG = [
2170
2170
  "options": [
2171
2171
  {
2172
2172
  "flags": "--with <principal>",
2173
- "description": "Email address, group id, or '@all' to remove the company-wide grant"
2173
+ "description": "Person uid, email address, group id, or '@all' to remove the grant"
2174
2174
  }
2175
2175
  ],
2176
2176
  "subcommands": []
@@ -10026,7 +10026,7 @@ export const COMMAND_CATALOG = [
10026
10026
  },
10027
10027
  {
10028
10028
  "flags": "--grant-context <base64url>",
10029
- "description": "Primary-authorized hire hierarchy and gate decision"
10029
+ "description": "Primary-authorized hire hierarchy context"
10030
10030
  },
10031
10031
  {
10032
10032
  "flags": "--json",
@@ -10095,7 +10095,7 @@ export const COMMAND_CATALOG = [
10095
10095
  },
10096
10096
  {
10097
10097
  "name": "backfill-grants",
10098
- "description": "Preview or apply missing hire grants for alive manager lanes",
10098
+ "description": "Preview missing hire grants for alive manager lanes; writes are disabled",
10099
10099
  "aliases": [],
10100
10100
  "hidden": false,
10101
10101
  "usage": "[options]",
@@ -10107,7 +10107,7 @@ export const COMMAND_CATALOG = [
10107
10107
  },
10108
10108
  {
10109
10109
  "flags": "--apply",
10110
- "description": "Write missing hire grants; only run before enforcement is enabled"
10110
+ "description": "Retained for compatibility; writes are disabled"
10111
10111
  },
10112
10112
  {
10113
10113
  "flags": "--json",
@@ -262,7 +262,7 @@ export function registerFilesCommand(program) {
262
262
  files
263
263
  .command("unshare <prefix>")
264
264
  .description("Remove a file access grant. A trailing slash targets the private-folder row exactly.")
265
- .requiredOption("--with <principal>", "Email address, group id, or '@all' to remove the company-wide grant")
265
+ .requiredOption("--with <principal>", "Person uid, email address, group id, or '@all' to remove the grant")
266
266
  .action(async (prefix, opts) => {
267
267
  try {
268
268
  // Unshare MUTATES access with no preview, and it resolves the target
@@ -278,7 +278,8 @@ export function registerFilesCommand(program) {
278
278
  const isAll = principal === "@all";
279
279
  const isEmail = !isAll && EMAIL_PATTERN.test(principal);
280
280
  const isGroup = !isAll && GROUP_ID_PATTERN.test(principal);
281
- if (!isAll && !isEmail && !isGroup) {
281
+ const isPerson = !isAll && !isEmail && !isGroup && PERSON_UID_PATTERN.test(principal);
282
+ if (!isAll && !isEmail && !isGroup && !isPerson) {
282
283
  console.error(chalk.red(`Invalid principal '${principal}': must be '@all', an email address, or a group id matching grp_<alphanumeric>`));
283
284
  process.exit(1);
284
285
  }
@@ -286,7 +287,9 @@ export function registerFilesCommand(program) {
286
287
  ? "company-wide"
287
288
  : isEmail
288
289
  ? "email"
289
- : "group";
290
+ : isGroup
291
+ ? "group"
292
+ : "person";
290
293
  const granteeId = isAll
291
294
  ? ""
292
295
  : isEmail
@@ -7002,7 +7002,7 @@ export function registerLanesCommand(parent, deps = {}) {
7002
7002
  .command("grant-target", { hidden: true })
7003
7003
  .argument("<lane-id>", "Lane id")
7004
7004
  .option("--hire <n>", "Non-negative safe integer hire grant")
7005
- .option("--grant-context <base64url>", "Primary-authorized hire hierarchy and gate decision")
7005
+ .option("--grant-context <base64url>", "Primary-authorized hire hierarchy context")
7006
7006
  .action(wrap(async (laneId, opts) => {
7007
7007
  await runGrantOwnerTarget(ctx, laneId, opts);
7008
7008
  }));
@@ -7027,9 +7027,9 @@ export function registerLanesCommand(parent, deps = {}) {
7027
7027
  }));
7028
7028
  lanes
7029
7029
  .command("backfill-grants")
7030
- .description("Preview or apply missing hire grants for alive manager lanes")
7030
+ .description("Preview missing hire grants for alive manager lanes; writes are disabled")
7031
7031
  .option("--dry-run", "Print proposed values without writing (default)")
7032
- .option("--apply", "Write missing hire grants; only run before enforcement is enabled")
7032
+ .option("--apply", "Retained for compatibility; writes are disabled")
7033
7033
  .action(wrap(async (opts) => {
7034
7034
  await runBackfillGrants(ctx, opts);
7035
7035
  }));
@@ -1,14 +1,11 @@
1
- export declare const PLAN_LIMIT_UPGRADE_FALLBACK_FLAG = "cli.plan-limit-upgrade-fallback";
2
- export declare const PLAN_LIMIT_UPGRADE_FALLBACK_DEFAULT = false;
3
1
  export interface PlanLimitUpgradeUrlChoices {
4
2
  topLevelUrl?: string | null;
5
3
  nestedUrl?: string | null;
6
4
  legacyUrl: string;
7
5
  }
8
- export declare function isPlanLimitUpgradeFallbackEnabled(): boolean;
9
6
  /** Resolve and attribute the final selected company billing destination. */
10
7
  export declare function selectPlanLimitUpgradeUrl(companySlug: string | undefined, choices: PlanLimitUpgradeUrlChoices): string;
11
8
  export declare function withCliLimitEntrySurface(value: string): string;
12
- /** Keep the existing destination and flag behavior while labeling company limit links. */
9
+ /** Select the standard upgrade destination and label company limit links. */
13
10
  export declare function planLimitUpgradeFallbackUrl(companySlug: string | undefined, previousFallbackUrl: string): string;
14
11
  //# sourceMappingURL=plan-limit-upgrade-url.d.ts.map
@@ -1,11 +1,5 @@
1
- import { resolveProcessFlagGate } from "../flag-registry.js";
2
1
  import { DEFAULT_VAULT_API_URL } from "../../utils/cognito-session.js";
3
2
  import { consoleOrigin } from "../../utils/console-origin.js";
4
- export const PLAN_LIMIT_UPGRADE_FALLBACK_FLAG = "cli.plan-limit-upgrade-fallback";
5
- export const PLAN_LIMIT_UPGRADE_FALLBACK_DEFAULT = false;
6
- export function isPlanLimitUpgradeFallbackEnabled() {
7
- return resolveProcessFlagGate(PLAN_LIMIT_UPGRADE_FALLBACK_FLAG, { fallback: PLAN_LIMIT_UPGRADE_FALLBACK_DEFAULT }, () => PLAN_LIMIT_UPGRADE_FALLBACK_DEFAULT);
8
- }
9
3
  function companyBillingUrl(companySlug) {
10
4
  const controlPlaneUrl = process.env.HQ_VAULT_API_URL?.trim() || DEFAULT_VAULT_API_URL;
11
5
  const origin = consoleOrigin(controlPlaneUrl);
@@ -19,20 +13,15 @@ function companyBillingUrl(companySlug) {
19
13
  }
20
14
  /** Resolve and attribute the final selected company billing destination. */
21
15
  export function selectPlanLimitUpgradeUrl(companySlug, choices) {
16
+ const topLevelUrl = choices.topLevelUrl?.trim() || undefined;
17
+ const nestedUrl = choices.nestedUrl?.trim() || undefined;
18
+ const generatedUrl = companyBillingUrl(companySlug);
22
19
  let selected;
23
- if (!isPlanLimitUpgradeFallbackEnabled()) {
24
- selected = choices.legacyUrl;
20
+ if (companySlug?.trim()) {
21
+ selected = generatedUrl ?? topLevelUrl ?? nestedUrl ?? choices.legacyUrl;
25
22
  }
26
23
  else {
27
- const topLevelUrl = choices.topLevelUrl?.trim() || undefined;
28
- const nestedUrl = choices.nestedUrl?.trim() || undefined;
29
- const generatedUrl = companyBillingUrl(companySlug);
30
- if (companySlug?.trim()) {
31
- selected = generatedUrl ?? topLevelUrl ?? nestedUrl ?? choices.legacyUrl;
32
- }
33
- else {
34
- selected = topLevelUrl ?? nestedUrl ?? generatedUrl ?? choices.legacyUrl;
35
- }
24
+ selected = topLevelUrl ?? nestedUrl ?? generatedUrl ?? choices.legacyUrl;
36
25
  }
37
26
  return withCliLimitEntrySurface(selected);
38
27
  }
@@ -68,7 +57,7 @@ export function withCliLimitEntrySurface(value) {
68
57
  }
69
58
  return value;
70
59
  }
71
- /** Keep the existing destination and flag behavior while labeling company limit links. */
60
+ /** Select the standard upgrade destination and label company limit links. */
72
61
  export function planLimitUpgradeFallbackUrl(companySlug, previousFallbackUrl) {
73
62
  return selectPlanLimitUpgradeUrl(companySlug, {
74
63
  legacyUrl: previousFallbackUrl,
@@ -7,5 +7,5 @@ export interface SyncRuntimeOptions {
7
7
  /** Classify the last hour of sync log lines into privacy-safe results. */
8
8
  export declare function analyzeSyncRuntimeLog(lines: readonly string[], options: SyncRuntimeOptions): CheckResult[];
9
9
  /** Read the machine's sync logs and client health file. */
10
- export declare function defaultSyncRuntimeResults(now: Date, hqHome?: string): CheckResult[];
10
+ export declare function defaultSyncRuntimeResults(now: Date, hqHome?: string, env?: NodeJS.ProcessEnv): CheckResult[];
11
11
  //# sourceMappingURL=sync-runtime.d.ts.map
@@ -9,6 +9,7 @@
9
9
  import * as fs from "node:fs";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
+ import { currentSyncLogFamily, SYNC_LOG_GENERATIONS, syncLogBasePaths, syncLogGenerationPath, } from "../../../utils/sync-log-sources.js";
12
13
  export const SYNC_RUNTIME_WINDOW_MS = 60 * 60_000;
13
14
  const LAG_SECONDS = 15 * 60;
14
15
  const LAG_MIN_RECEIPTS = 3;
@@ -32,6 +33,32 @@ function parseRecord(rest) {
32
33
  return null;
33
34
  }
34
35
  }
36
+ function timestampFromRecord(record) {
37
+ if (!record)
38
+ return null;
39
+ for (const key of ["time", "timestamp", "ts"]) {
40
+ const value = record[key];
41
+ if (typeof value === "number" && Number.isFinite(value)) {
42
+ const millis = value < 100_000_000_000 ? value * 1000 : value;
43
+ if (Number.isFinite(millis))
44
+ return millis;
45
+ }
46
+ if (typeof value === "string") {
47
+ const parsed = Date.parse(value);
48
+ if (Number.isFinite(parsed))
49
+ return parsed;
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+ function lineTimestamp(line) {
55
+ const stamp = TIMESTAMP.exec(line);
56
+ if (stamp) {
57
+ const parsed = Date.parse(stamp[1]);
58
+ return Number.isFinite(parsed) ? parsed : null;
59
+ }
60
+ return timestampFromRecord(parseRecord(line));
61
+ }
35
62
  /** Classify the last hour of sync log lines into privacy-safe results. */
36
63
  export function analyzeSyncRuntimeLog(lines, options) {
37
64
  const since = options.now - SYNC_RUNTIME_WINDOW_MS;
@@ -40,13 +67,13 @@ export function analyzeSyncRuntimeLog(lines, options) {
40
67
  let starts = 0;
41
68
  let deviceLimited = 0;
42
69
  for (const line of lines) {
43
- const stamp = TIMESTAMP.exec(line);
44
- if (!stamp)
70
+ const time = lineTimestamp(line);
71
+ if (time === null)
45
72
  continue;
46
- const time = Date.parse(stamp[1]);
47
73
  if (!Number.isFinite(time) || time < since || time > options.now)
48
74
  continue;
49
- const rest = line.slice(stamp[0].length);
75
+ const stamp = TIMESTAMP.exec(line);
76
+ const rest = stamp ? line.slice(stamp[0].length) : line;
50
77
  if (DEVICE_LIMIT.test(rest))
51
78
  deviceLimited += 1;
52
79
  const record = parseRecord(rest);
@@ -61,7 +88,8 @@ export function analyzeSyncRuntimeLog(lines, options) {
61
88
  else if (event === "receiver.start") {
62
89
  starts += 1;
63
90
  }
64
- if ((record.type === "receiver" || record.type === "error") &&
91
+ if ((record.type === "receiver" || record.type === "error" ||
92
+ (typeof event === "string" && event.startsWith("receiver."))) &&
65
93
  PERMISSION.test(JSON.stringify(record.err ?? record.message ?? ""))) {
66
94
  denied += 1;
67
95
  }
@@ -80,7 +108,7 @@ export function analyzeSyncRuntimeLog(lines, options) {
80
108
  checkId: `sync.runtime.${check.id}`,
81
109
  reasonCode: check.reasonCode,
82
110
  message: `${check.count} ${check.label} in the last hour.`,
83
- remediation: "Run `hq sync status` and check ~/.hq/logs/hq-sync.log.",
111
+ remediation: "Run `hq sync status` and inspect the selected sync log under ~/.hq/logs/, ~/.hq/daemon/logs/, or the configured HQ_DAEMON_DIR/logs/.",
84
112
  }
85
113
  : {
86
114
  status: "PASS",
@@ -88,7 +116,6 @@ export function analyzeSyncRuntimeLog(lines, options) {
88
116
  message: `${check.count} ${check.label} in the last hour.`,
89
117
  });
90
118
  }
91
- const LOG_GENERATIONS = 4;
92
119
  function readLogTail(file) {
93
120
  const fd = fs.openSync(file, "r");
94
121
  try {
@@ -104,9 +131,9 @@ function readLogTail(file) {
104
131
  }
105
132
  function firstTimestamp(lines) {
106
133
  for (const line of lines) {
107
- const stamp = TIMESTAMP.exec(line);
108
- if (stamp)
109
- return Date.parse(stamp[1]);
134
+ const timestamp = lineTimestamp(line);
135
+ if (timestamp !== null)
136
+ return timestamp;
110
137
  }
111
138
  return null;
112
139
  }
@@ -114,15 +141,21 @@ function firstTimestamp(lines) {
114
141
  * Lines from the active log and as many rotated generations as needed to
115
142
  * cover the window. Throws when a present log cannot be read.
116
143
  */
117
- function readWindowLines(logFile, since) {
144
+ function readWindowLines(logFile, since, daemonLog) {
118
145
  const generations = [];
119
- for (let n = 0; n < LOG_GENERATIONS; n += 1) {
120
- const file = n === 0 ? logFile : `${logFile}.${n}`;
146
+ for (let n = 0; n < SYNC_LOG_GENERATIONS; n += 1) {
147
+ const file = syncLogGenerationPath(logFile, n);
121
148
  if (!fs.existsSync(file))
122
- break;
149
+ continue;
123
150
  const lines = readLogTail(file);
124
- generations.unshift(lines);
125
- const first = firstTimestamp(lines);
151
+ const fallbackTime = daemonLog ? fs.statSync(file).mtimeMs : null;
152
+ const timestampedLines = fallbackTime === null
153
+ ? lines
154
+ : lines.map((line) => lineTimestamp(line) === null
155
+ ? `${new Date(fallbackTime).toISOString()} ${line}`
156
+ : line);
157
+ generations.unshift(timestampedLines);
158
+ const first = firstTimestamp(timestampedLines);
126
159
  if (first !== null && first <= since)
127
160
  break;
128
161
  }
@@ -139,15 +172,32 @@ function readConflictCount(file) {
139
172
  }
140
173
  }
141
174
  /** Read the machine's sync logs and client health file. */
142
- export function defaultSyncRuntimeResults(now, hqHome = path.join(os.homedir(), ".hq")) {
175
+ export function defaultSyncRuntimeResults(now, hqHome = path.join(os.homedir(), ".hq"), env = process.env) {
143
176
  const options = {
144
177
  now: now.getTime(),
145
178
  conflictCount: readConflictCount(path.join(hqHome, "client-health.json")),
146
179
  };
147
- const logFile = path.join(hqHome, "logs", "hq-sync.log");
180
+ const paths = syncLogBasePaths(hqHome, env);
181
+ const familyModifiedMs = (family) => {
182
+ let newest = null;
183
+ for (let generation = 0; generation < SYNC_LOG_GENERATIONS; generation += 1) {
184
+ try {
185
+ const stat = fs.lstatSync(syncLogGenerationPath(paths[family], generation));
186
+ if (!stat.isFile() && !stat.isDirectory())
187
+ continue;
188
+ newest = newest === null ? stat.mtimeMs : Math.max(newest, stat.mtimeMs);
189
+ }
190
+ catch {
191
+ // Missing or inaccessible generations do not make a source family newer.
192
+ }
193
+ }
194
+ return newest;
195
+ };
196
+ const family = currentSyncLogFamily(familyModifiedMs("desktop"), familyModifiedMs("daemon"));
197
+ const logFile = family === null ? paths.desktop : paths[family];
148
198
  let lines;
149
199
  try {
150
- lines = readWindowLines(logFile, options.now - SYNC_RUNTIME_WINDOW_MS);
200
+ lines = readWindowLines(logFile, options.now - SYNC_RUNTIME_WINDOW_MS, family === "daemon");
151
201
  }
152
202
  catch {
153
203
  return [
@@ -37,7 +37,7 @@ import * as fs from "node:fs";
37
37
  import * as os from "node:os";
38
38
  import * as path from "node:path";
39
39
  import { resolveProcessFlagCompanyContext } from "./flag-registry.js";
40
- import { isPlanLimitUpgradeFallbackEnabled, planLimitUpgradeFallbackUrl, selectPlanLimitUpgradeUrl, } from "./billing/plan-limit-upgrade-url.js";
40
+ import { planLimitUpgradeFallbackUrl, selectPlanLimitUpgradeUrl, } from "./billing/plan-limit-upgrade-url.js";
41
41
  export const PLAN_LIMIT_UPGRADE_URL = "https://hq.computer/billing/upgrade";
42
42
  /**
43
43
  * Cadence of the over-limit per-turn line (US-036). Was once a day; the nag
@@ -230,9 +230,7 @@ export function recordPlanLimitStatus(body, opts = {}) {
230
230
  : null;
231
231
  const topLevelUpgradeUrl = parseUpgradeUrl(bodyRecord?.upgradeUrl);
232
232
  const nestedUpgradeUrl = parseNestedUpgradeUrl(body);
233
- const companySlug = isPlanLimitUpgradeFallbackEnabled()
234
- ? resolveProcessFlagCompanyContext().companySlug
235
- : undefined;
233
+ const companySlug = resolveProcessFlagCompanyContext().companySlug;
236
234
  resolvedUpgradeUrl = selectPlanLimitUpgradeUrl(companySlug, {
237
235
  topLevelUrl: topLevelUpgradeUrl,
238
236
  nestedUrl: nestedUpgradeUrl,
@@ -13,8 +13,9 @@
13
13
  * (`hq-sync.log.1`, `.2`, `.3`) are eligible too — just after a rotation
14
14
  * the active file is nearly empty and the history lives in `.1`.
15
15
  * `logs/mcp-registry.log` is the CLI's own append-only MCP audit trail.
16
- * Any future `*.log`/`*.jsonl` dropped into `~/.hq/logs/` is picked up
17
- * automatically.
16
+ * The daemon runner's `daemon/logs/sync.log` and its three rotations are
17
+ * eligible too. When both sync families exist, only the newer family is
18
+ * attached.
18
19
  *
19
20
  * - STATE SNAPSHOTS (`state`) — small JSON files that describe where sync
20
21
  * and client health currently stand: the in-flight sync phase and conflict
@@ -36,8 +37,9 @@
36
37
  *
37
38
  * 1. ALLOWLIST, NOT A SCAN. `~/.hq` also holds `cognito-tokens.json`,
38
39
  * `deploy-passwords.json`, and `secrets-cache/`. Only `*.log` files
39
- * directly inside `~/.hq/logs/` plus the two known root-level boot logs
40
- * are ever eligible. A recursive walk over `~/.hq` is a credential leak
40
+ * directly inside `~/.hq/logs/`, the exact daemon sync-log generations
41
+ * under the configured daemon root, and the two known root-level boot
42
+ * logs are eligible. A recursive walk over `~/.hq` is a credential leak
41
43
  * and must never be introduced here.
42
44
  *
43
45
  * 2. NO SYMLINKS. Eligibility is decided with `lstat`, and a candidate must
@@ -235,12 +237,13 @@ export declare function feedbackLogsEnabled(env?: NodeJS.ProcessEnv): boolean;
235
237
  * Enumerate eligible log files under `hqDir`.
236
238
  *
237
239
  * Eligibility is the allowlist described in the module header: `*.log` files
238
- * directly inside `hqDir/logs/`, plus {@link FEEDBACK_ROOT_LOG_FILENAMES} at
239
- * the root. Every candidate must be a REGULAR file by `lstat` — directories
240
- * and, critically, symlinks are rejected. Returned newest-modified first so
241
- * the most relevant log is offered the budget before older ones.
240
+ * directly inside `hqDir/logs/`, the exact daemon sync-log generations, plus
241
+ * {@link FEEDBACK_ROOT_LOG_FILENAMES} at the root. Every candidate must be a
242
+ * REGULAR file by `lstat` — directories and, critically, symlinks are rejected.
243
+ * Returned newest-modified first so the most relevant log is offered budget
244
+ * before older ones.
242
245
  */
243
- export declare function discoverLogFiles(hqDir: string): LogCandidate[];
246
+ export declare function discoverLogFiles(hqDir: string, env?: NodeJS.ProcessEnv): LogCandidate[];
244
247
  /**
245
248
  * Read the last `maxBytes` of a file.
246
249
  *
@@ -314,6 +317,8 @@ export interface CollectFeedbackLogsOptions {
314
317
  hqDir?: string;
315
318
  /** Override the home directory used to derive `~/.hq` (tests). */
316
319
  homeDir?: string;
320
+ /** Override process environment for configured log paths (tests). */
321
+ env?: NodeJS.ProcessEnv;
317
322
  }
318
323
  /**
319
324
  * Collect redacted `~/.hq` evidence within `budgetBytes`.
@@ -13,8 +13,9 @@
13
13
  * (`hq-sync.log.1`, `.2`, `.3`) are eligible too — just after a rotation
14
14
  * the active file is nearly empty and the history lives in `.1`.
15
15
  * `logs/mcp-registry.log` is the CLI's own append-only MCP audit trail.
16
- * Any future `*.log`/`*.jsonl` dropped into `~/.hq/logs/` is picked up
17
- * automatically.
16
+ * The daemon runner's `daemon/logs/sync.log` and its three rotations are
17
+ * eligible too. When both sync families exist, only the newer family is
18
+ * attached.
18
19
  *
19
20
  * - STATE SNAPSHOTS (`state`) — small JSON files that describe where sync
20
21
  * and client health currently stand: the in-flight sync phase and conflict
@@ -36,8 +37,9 @@
36
37
  *
37
38
  * 1. ALLOWLIST, NOT A SCAN. `~/.hq` also holds `cognito-tokens.json`,
38
39
  * `deploy-passwords.json`, and `secrets-cache/`. Only `*.log` files
39
- * directly inside `~/.hq/logs/` plus the two known root-level boot logs
40
- * are ever eligible. A recursive walk over `~/.hq` is a credential leak
40
+ * directly inside `~/.hq/logs/`, the exact daemon sync-log generations
41
+ * under the configured daemon root, and the two known root-level boot
42
+ * logs are eligible. A recursive walk over `~/.hq` is a credential leak
41
43
  * and must never be introduced here.
42
44
  *
43
45
  * 2. NO SYMLINKS. Eligibility is decided with `lstat`, and a candidate must
@@ -58,6 +60,7 @@
58
60
  import * as fs from "node:fs";
59
61
  import * as os from "node:os";
60
62
  import * as path from "node:path";
63
+ import { currentSyncLogFamily, SYNC_LOG_RELATIVE_PATHS, SYNC_LOG_GENERATIONS, syncLogBasePaths, syncLogFamilyForRelativePath, syncLogGenerationPath, } from "./sync-log-sources.js";
61
64
  /**
62
65
  * Ceiling on the total redacted log text attached to one submission.
63
66
  *
@@ -279,15 +282,15 @@ function lstatOrNull(absPath) {
279
282
  * Enumerate eligible log files under `hqDir`.
280
283
  *
281
284
  * Eligibility is the allowlist described in the module header: `*.log` files
282
- * directly inside `hqDir/logs/`, plus {@link FEEDBACK_ROOT_LOG_FILENAMES} at
283
- * the root. Every candidate must be a REGULAR file by `lstat` — directories
284
- * and, critically, symlinks are rejected. Returned newest-modified first so
285
- * the most relevant log is offered the budget before older ones.
285
+ * directly inside `hqDir/logs/`, the exact daemon sync-log generations, plus
286
+ * {@link FEEDBACK_ROOT_LOG_FILENAMES} at the root. Every candidate must be a
287
+ * REGULAR file by `lstat` — directories and, critically, symlinks are rejected.
288
+ * Returned newest-modified first so the most relevant log is offered budget
289
+ * before older ones.
286
290
  */
287
- export function discoverLogFiles(hqDir) {
291
+ export function discoverLogFiles(hqDir, env = process.env) {
288
292
  const candidates = [];
289
- const consider = (relName) => {
290
- const absPath = path.join(hqDir, relName);
293
+ const consider = (relName, absPath = path.join(hqDir, relName)) => {
291
294
  const st = lstatOrNull(absPath);
292
295
  // isFile() is false for both directories and symlinks under lstat. A
293
296
  // symlink here would be the credential-exfiltration path (see header).
@@ -313,11 +316,37 @@ export function discoverLogFiles(hqDir) {
313
316
  continue;
314
317
  consider(`${FEEDBACK_LOGS_DIRNAME}/${entry}`);
315
318
  }
319
+ const daemonLogPath = syncLogBasePaths(hqDir, env).daemon;
320
+ for (let generation = 0; generation < SYNC_LOG_GENERATIONS; generation += 1) {
321
+ const relName = syncLogGenerationPath(SYNC_LOG_RELATIVE_PATHS.daemon, generation);
322
+ const absPath = syncLogGenerationPath(daemonLogPath, generation);
323
+ consider(relName, absPath);
324
+ }
316
325
  for (const filename of FEEDBACK_ROOT_LOG_FILENAMES) {
317
326
  consider(filename);
318
327
  }
319
- candidates.sort((a, b) => b.modifiedMs - a.modifiedMs);
320
- return candidates;
328
+ let desktopModifiedMs = null;
329
+ let daemonModifiedMs = null;
330
+ for (const candidate of candidates) {
331
+ const family = syncLogFamilyForRelativePath(candidate.name);
332
+ if (family === "desktop") {
333
+ desktopModifiedMs = desktopModifiedMs === null
334
+ ? candidate.modifiedMs
335
+ : Math.max(desktopModifiedMs, candidate.modifiedMs);
336
+ }
337
+ else if (family === "daemon") {
338
+ daemonModifiedMs = daemonModifiedMs === null
339
+ ? candidate.modifiedMs
340
+ : Math.max(daemonModifiedMs, candidate.modifiedMs);
341
+ }
342
+ }
343
+ const selectedFamily = currentSyncLogFamily(desktopModifiedMs, daemonModifiedMs);
344
+ const selected = candidates.filter((candidate) => {
345
+ const family = syncLogFamilyForRelativePath(candidate.name);
346
+ return family === null || family === selectedFamily;
347
+ });
348
+ selected.sort((a, b) => b.modifiedMs - a.modifiedMs);
349
+ return selected;
321
350
  }
322
351
  /**
323
352
  * Read the last `maxBytes` of a file.
@@ -546,7 +575,7 @@ export function collectFeedbackLogs(opts = {}) {
546
575
  let logCandidates;
547
576
  let stateCandidates;
548
577
  try {
549
- logCandidates = discoverLogFiles(hqDir);
578
+ logCandidates = discoverLogFiles(hqDir, opts.env);
550
579
  // Order matters: root status documents first, then the lock/claim files
551
580
  // (highest signal for a hang), then scheduled-job status. Under a tight
552
581
  // budget the earlier ones win, and that is the intended priority.
@@ -11,10 +11,11 @@
11
11
  * cross the wire; there is no string field on it for anything sensitive to
12
12
  * hide inside.
13
13
  *
14
- * Sources (metadata only, via `fs.stat` — full content is read only for the
14
+ * Sources (metadata only, via `fs.lstat` — full content is read only for the
15
15
  * bounded error-line count below, and even then only a capped tail window,
16
16
  * and even then only an integer count is ever returned):
17
- * - the hq-sync desktop app's log file, `~/.hq/logs/hq-sync.log`
17
+ * - the newest available desktop or daemon sync-log family and its rotations
18
+ * under `~/.hq/logs/` or `~/.hq/daemon/logs/`
18
19
  * - the CLI's own per-company sync journals, enumerated via `listJournals()`
19
20
  * (never a hand-reconstructed path — see client-health.ts's note on the
20
21
  * single-path-reconstruction regression this mirrors).
@@ -42,6 +43,7 @@ export declare function countRecentErrorLines(filePath: string, maxBytes?: numbe
42
43
  export interface CollectLocalFilesOverviewDeps {
43
44
  now?: () => Date;
44
45
  homeDir?: () => string;
46
+ env?: NodeJS.ProcessEnv;
45
47
  journals?: () => readonly {
46
48
  path: string;
47
49
  }[];
@@ -11,10 +11,11 @@
11
11
  * cross the wire; there is no string field on it for anything sensitive to
12
12
  * hide inside.
13
13
  *
14
- * Sources (metadata only, via `fs.stat` — full content is read only for the
14
+ * Sources (metadata only, via `fs.lstat` — full content is read only for the
15
15
  * bounded error-line count below, and even then only a capped tail window,
16
16
  * and even then only an integer count is ever returned):
17
- * - the hq-sync desktop app's log file, `~/.hq/logs/hq-sync.log`
17
+ * - the newest available desktop or daemon sync-log family and its rotations
18
+ * under `~/.hq/logs/` or `~/.hq/daemon/logs/`
18
19
  * - the CLI's own per-company sync journals, enumerated via `listJournals()`
19
20
  * (never a hand-reconstructed path — see client-health.ts's note on the
20
21
  * single-path-reconstruction regression this mirrors).
@@ -28,6 +29,7 @@ import * as fs from "node:fs";
28
29
  import * as os from "node:os";
29
30
  import * as path from "node:path";
30
31
  import { listJournals } from "@indigoai-us/hq-cloud";
32
+ import { currentSyncLogFamily, SYNC_LOG_GENERATIONS, syncLogBasePaths, syncLogGenerationPath, } from "./sync-log-sources.js";
31
33
  import { CLIENT_HEALTH_MAX_ERROR_LINE_COUNT, CLIENT_HEALTH_MAX_FILE_AGE_SECONDS, CLIENT_HEALTH_MAX_FILE_SIZE_BYTES, } from "./client-health-contract.js";
32
34
  /**
33
35
  * Only the last N bytes of the sync log are read for error-line counting —
@@ -37,7 +39,8 @@ import { CLIENT_HEALTH_MAX_ERROR_LINE_COUNT, CLIENT_HEALTH_MAX_FILE_AGE_SECONDS,
37
39
  export const LOCAL_FILES_OVERVIEW_TAIL_READ_BYTES = 2 * 1024 * 1024; // 2 MiB
38
40
  /** Case-insensitive marker matched against each line (or JSON-lines `level`). */
39
41
  const ERROR_MARKER = /error/i;
40
- const JSON_LEVEL_ERROR = /"level"\s*:\s*"error"/i;
42
+ const JSON_LEVEL_ERROR = /"level"\s*:\s*"error"\s*(?=[,}])/i;
43
+ const JSON_NUMERIC_LEVEL = /"level"\s*:\s*(-?\d+(?:\.\d+)?)\s*(?=[,}])/;
41
44
  const ABSENT = { exists: false, sizeBytes: 0, ageSeconds: 0 };
42
45
  function clampInt(value, max) {
43
46
  if (!Number.isFinite(value) || value < 0)
@@ -102,6 +105,12 @@ export function countRecentErrorLines(filePath, maxBytes = LOCAL_FILES_OVERVIEW_
102
105
  for (const line of text.split("\n")) {
103
106
  if (count >= cap)
104
107
  break;
108
+ const numericLevel = JSON_NUMERIC_LEVEL.exec(line)?.[1];
109
+ if (numericLevel !== undefined) {
110
+ if (Number(numericLevel) >= 50)
111
+ count += 1;
112
+ continue;
113
+ }
105
114
  if (ERROR_MARKER.test(line) || JSON_LEVEL_ERROR.test(line))
106
115
  count += 1;
107
116
  }
@@ -133,9 +142,41 @@ export function collectLocalFilesOverview(deps = {}) {
133
142
  const nowMs = now.getTime();
134
143
  const homeDir = (deps.homeDir ?? os.homedir)();
135
144
  const countErrorLines = deps.countErrorLines ?? countRecentErrorLines;
136
- const syncLogPath = path.join(homeDir, ".hq", "logs", "hq-sync.log");
137
- const syncLogFacts = statFacts(syncLogPath, nowMs);
138
- const recentErrorLineCount = syncLogFacts.exists ? countErrorLines(syncLogPath) : 0;
145
+ const hqDir = path.join(homeDir, ".hq");
146
+ const syncLogPaths = syncLogBasePaths(hqDir, deps.env);
147
+ const generations = {
148
+ desktop: [],
149
+ daemon: [],
150
+ };
151
+ const familyModifiedMs = (family) => {
152
+ let newest = null;
153
+ for (let generation = 0; generation < SYNC_LOG_GENERATIONS; generation += 1) {
154
+ const file = syncLogGenerationPath(syncLogPaths[family], generation);
155
+ try {
156
+ const stat = fs.lstatSync(file);
157
+ if (!stat.isFile())
158
+ continue;
159
+ generations[family].push({ file, modifiedMs: stat.mtimeMs });
160
+ newest = newest === null ? stat.mtimeMs : Math.max(newest, stat.mtimeMs);
161
+ }
162
+ catch {
163
+ // Missing, denied, or raced generations are absent from this overview.
164
+ }
165
+ }
166
+ return newest;
167
+ };
168
+ const family = currentSyncLogFamily(familyModifiedMs("desktop"), familyModifiedMs("daemon"));
169
+ const selectedFamily = family ?? "desktop";
170
+ const selectedFiles = generations[selectedFamily];
171
+ const activePath = syncLogPaths[selectedFamily];
172
+ const activeFacts = statFacts(activePath, nowMs);
173
+ const freshestGeneration = [...selectedFiles].sort((a, b) => b.modifiedMs - a.modifiedMs)[0];
174
+ const syncLogPath = activeFacts.exists ? activePath : freshestGeneration?.file;
175
+ const syncLogFacts = syncLogPath ? statFacts(syncLogPath, nowMs) : ABSENT;
176
+ let recentErrorLineCount = 0;
177
+ for (const generation of selectedFiles) {
178
+ recentErrorLineCount = Math.min(CLIENT_HEALTH_MAX_ERROR_LINE_COUNT, recentErrorLineCount + countErrorLines(generation.file));
179
+ }
139
180
  let journalEntries;
140
181
  try {
141
182
  journalEntries = (deps.journals ?? listJournals)();
@@ -0,0 +1,15 @@
1
+ /** Stable relative names used for feedback attachments. */
2
+ export declare const SYNC_LOG_RELATIVE_PATHS: {
3
+ readonly desktop: "logs/hq-sync.log";
4
+ readonly daemon: "daemon/logs/sync.log";
5
+ };
6
+ export type SyncLogFamily = keyof typeof SYNC_LOG_RELATIVE_PATHS;
7
+ /** Read the active file plus the three retained rotations. */
8
+ export declare const SYNC_LOG_GENERATIONS = 4;
9
+ /** Resolve both sync-log families, honoring the daemon's configured root. */
10
+ export declare function syncLogBasePaths(hqDir: string, env?: NodeJS.ProcessEnv): Record<SyncLogFamily, string>;
11
+ export declare function syncLogGenerationPath(basePath: string, generation: number): string;
12
+ export declare function syncLogFamilyForRelativePath(name: string): SyncLogFamily | null;
13
+ /** Choose the most recently written family; desktop wins a timestamp tie. */
14
+ export declare function currentSyncLogFamily(desktopModifiedMs: number | null, daemonModifiedMs: number | null): SyncLogFamily | null;
15
+ //# sourceMappingURL=sync-log-sources.d.ts.map
@@ -0,0 +1,36 @@
1
+ import * as path from "node:path";
2
+ import { daemonPaths } from "../lib/daemon/paths.js";
3
+ /** Stable relative names used for feedback attachments. */
4
+ export const SYNC_LOG_RELATIVE_PATHS = {
5
+ desktop: "logs/hq-sync.log",
6
+ daemon: "daemon/logs/sync.log",
7
+ };
8
+ /** Read the active file plus the three retained rotations. */
9
+ export const SYNC_LOG_GENERATIONS = 4;
10
+ /** Resolve both sync-log families, honoring the daemon's configured root. */
11
+ export function syncLogBasePaths(hqDir, env = process.env) {
12
+ const daemonLogDir = daemonPaths(path.dirname(hqDir), env).logsDir;
13
+ return {
14
+ desktop: path.join(hqDir, SYNC_LOG_RELATIVE_PATHS.desktop),
15
+ daemon: path.join(daemonLogDir, "sync.log"),
16
+ };
17
+ }
18
+ export function syncLogGenerationPath(basePath, generation) {
19
+ return generation === 0 ? basePath : `${basePath}.${generation}`;
20
+ }
21
+ export function syncLogFamilyForRelativePath(name) {
22
+ if (/^logs\/hq-sync\.log(?:\.\d+)?$/.test(name))
23
+ return "desktop";
24
+ if (/^daemon\/logs\/sync\.log(?:\.\d+)?$/.test(name))
25
+ return "daemon";
26
+ return null;
27
+ }
28
+ /** Choose the most recently written family; desktop wins a timestamp tie. */
29
+ export function currentSyncLogFamily(desktopModifiedMs, daemonModifiedMs) {
30
+ if (desktopModifiedMs === null)
31
+ return daemonModifiedMs === null ? null : "daemon";
32
+ if (daemonModifiedMs === null)
33
+ return "desktop";
34
+ return daemonModifiedMs > desktopModifiedMs ? "daemon" : "desktop";
35
+ }
36
+ //# sourceMappingURL=sync-log-sources.js.map
@@ -390,6 +390,7 @@ interface EnforceUpdateDeps {
390
390
  performUpdateString?: (command: string) => UpdateResult;
391
391
  resolveInstall?: () => RunningInstall;
392
392
  runner?: UpdateRunner;
393
+ platform?: NodeJS.Platform;
393
394
  cleanStale?: (prefix: string) => string[];
394
395
  acquireLock?: () => UpdateLockHandle | null;
395
396
  /**
@@ -1224,7 +1224,12 @@ function attemptRequiredUpdate(decision, deps, install) {
1224
1224
  // Never for pnpm or Bun: elevation changes the package manager's global home,
1225
1225
  // leaving the user's shim untouched while reporting success. A failed
1226
1226
  // non-npm update must surface instead.
1227
- if (!result.ok && result.code !== "ETIMEDOUT" && primaryCmd && !isManagedOutsideNpm) {
1227
+ const platform = deps.platform ?? process.platform;
1228
+ if (platform !== "win32" &&
1229
+ !result.ok &&
1230
+ result.code !== "ETIMEDOUT" &&
1231
+ primaryCmd &&
1232
+ !isManagedOutsideNpm) {
1228
1233
  console.error(chalk.dim(` Update failed unprivileged; retrying with: sudo -n ${primaryCmd} ${primaryArgs.join(" ")}`));
1229
1234
  const sudoResult = performUpdateCommand("sudo", ["-n", primaryCmd, ...primaryArgs], runner);
1230
1235
  if (sudoResult.ok)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.269.0",
3
+ "version": "5.270.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {