@indigoai-us/hq-cli 5.269.0 → 5.271.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.
@@ -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.271.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {