@indigoai-us/hq-cli 5.332.0 → 5.334.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 +8 -0
- package/dist/command-catalog.generated.d.ts +1 -1
- package/dist/command-catalog.generated.js +1 -1
- package/dist/commands/lanes-keep-alive-audit.js +1 -1
- package/dist/lib/install/claude-targets.js +1 -1
- package/dist/lib/lanes/keep-alive-audit.d.ts +1 -0
- package/dist/lib/lanes/keep-alive-audit.js +19 -11
- package/dist/utils/cli-telemetry.d.ts +2 -0
- package/dist/utils/cli-telemetry.js +84 -0
- package/dist/utils/self-update.js +3 -0
- package/dist/utils/update-command-supervisor.cjs +1 -0
- package/dist/utils/version-gate.js +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.334.0] — 2026-10-03
|
|
6
|
+
- Keep keep-alive audit verdicts for expired stops independent of the lane's current state, and report restarts recorded after the grace window separately.
|
|
7
|
+
|
|
8
|
+
## [5.333.0] — 2026-10-03
|
|
9
|
+
- Limit session-start telemetry to 30 events per machine per rolling minute.
|
|
10
|
+
- Start the globally registered HQ hook with BASH_ENV cleared, so an inherited shell profile no longer runs before every hook event.
|
|
11
|
+
- Report non-writable npm prefixes as informational while keeping the repair guidance visible, and attribute npm `EBUSY` update exits with a bounded class.
|
|
12
|
+
|
|
5
13
|
## [5.332.0] — 2026-10-02
|
|
6
14
|
- Match macOS shell behavior in native core commands for text, diagnostics, and file modes.
|
|
7
15
|
- `hq mcp status` now recognizes the real Claude plugin MCP definition and Codex PATH commands, while global and `hq-anywhere` pack installs share one Codex hook per event and one `hq` MCP server.
|
|
@@ -11105,7 +11105,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
11105
11105
|
readonly subcommands: readonly [];
|
|
11106
11106
|
}, {
|
|
11107
11107
|
readonly name: "keep-alive-audit";
|
|
11108
|
-
readonly description: "Audit lead lane keep-alive timing";
|
|
11108
|
+
readonly description: "Audit lead lane keep-alive timing. A later envelope proves another turn ran and may reclassify an expired stop as restarted_untimed when no launch record remains; late_restarts_after_window counts known launches after the grace window.";
|
|
11109
11109
|
readonly aliases: readonly [];
|
|
11110
11110
|
readonly hidden: false;
|
|
11111
11111
|
readonly helpOption: true;
|
|
@@ -13486,7 +13486,7 @@ export const COMMAND_CATALOG = [
|
|
|
13486
13486
|
},
|
|
13487
13487
|
{
|
|
13488
13488
|
"name": "keep-alive-audit",
|
|
13489
|
-
"description": "Audit lead lane keep-alive timing",
|
|
13489
|
+
"description": "Audit lead lane keep-alive timing. A later envelope proves another turn ran and may reclassify an expired stop as restarted_untimed when no launch record remains; late_restarts_after_window counts known launches after the grace window.",
|
|
13490
13490
|
"aliases": [],
|
|
13491
13491
|
"hidden": false,
|
|
13492
13492
|
"helpOption": true,
|
|
@@ -2,7 +2,7 @@ import { buildKeepAliveAudit, formatKeepAliveAuditText } from "../lib/lanes/keep
|
|
|
2
2
|
export function registerLaneKeepAliveAuditCommand(parent, deps) {
|
|
3
3
|
parent
|
|
4
4
|
.command("keep-alive-audit")
|
|
5
|
-
.description("Audit lead lane keep-alive timing")
|
|
5
|
+
.description("Audit lead lane keep-alive timing. A later envelope proves another turn ran and may reclassify an expired stop as restarted_untimed when no launch record remains; late_restarts_after_window counts known launches after the grace window.")
|
|
6
6
|
.option("--since <iso>", "Include stopped turns at or after this ISO date-time")
|
|
7
7
|
.option("--lane <id>", "Audit one exact lane id")
|
|
8
8
|
.option("--json", "Print the report as JSON")
|
|
@@ -52,7 +52,7 @@ export function hookCommand(hqRoot, event, target = 'master') {
|
|
|
52
52
|
const skipInsideRoot = `if [ -d "\${CLAUDE_PROJECT_DIR:-}" ] && [ "\${CLAUDE_PROJECT_DIR}" -ef ${shellQuote(hqRoot)} ]; then exit 0; fi; `;
|
|
53
53
|
if (target === 'shim')
|
|
54
54
|
return `${skipInsideRoot}exec /bin/sh ${shellQuote(shimPath(hqRoot))} ${shellQuote(event)} --runtime claude`;
|
|
55
|
-
return `${skipInsideRoot}exec bash ${shellQuote(masterHookPath(hqRoot))} ${shellQuote(event)}`;
|
|
55
|
+
return `${skipInsideRoot}BASH_ENV=/dev/null exec bash ${shellQuote(masterHookPath(hqRoot))} ${shellQuote(event)}`;
|
|
56
56
|
}
|
|
57
57
|
/** Quote one argument for the POSIX shell used by Claude Code command hooks. */
|
|
58
58
|
function shellQuote(value) {
|
|
@@ -19,6 +19,7 @@ interface KeepAliveAuditSummary {
|
|
|
19
19
|
by_verdict: Record<KeepAliveAuditVerdict, number>;
|
|
20
20
|
not_restarted: number;
|
|
21
21
|
restarted_untimed: number;
|
|
22
|
+
late_restarts_after_window: number;
|
|
22
23
|
late_over_10m: number;
|
|
23
24
|
early_wake_by_others: number;
|
|
24
25
|
self_wake: number;
|
|
@@ -4,7 +4,6 @@ import { LEAD_LANE_KEEP_ALIVE_BASE_BACKOFF_MS } from "./keep-alive.js";
|
|
|
4
4
|
import { LanesError } from "./errors.js";
|
|
5
5
|
import { laneRunDir } from "./spawn.js";
|
|
6
6
|
import { listQuestionIds, readAllLanes, tryReadQuestion } from "./store.js";
|
|
7
|
-
import { HISTORY_CAP } from "./types.js";
|
|
8
7
|
function readDoneClassifications(hqRoot, laneId) {
|
|
9
8
|
const root = fs.realpathSync(hqRoot);
|
|
10
9
|
const watcherLog = path.join(laneRunDir(root, laneId), "logs", "watcher.out");
|
|
@@ -309,17 +308,21 @@ function firstRestartAfter(lane, stop, nextEnvelopeMs) {
|
|
|
309
308
|
// cannot establish its launch time.
|
|
310
309
|
return { at: null, trigger: "keep_alive", caller: null };
|
|
311
310
|
}
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
return
|
|
318
|
-
|
|
319
|
-
|
|
311
|
+
const interruptedKeepAliveResume = (lane.keep_alive_history ?? []).some((entry) => {
|
|
312
|
+
const at = parseTime(entry.at);
|
|
313
|
+
return entry.resumed_from !== "done" && at !== undefined && at > stop.mtimeMs && at < nextEnvelopeMs;
|
|
314
|
+
});
|
|
315
|
+
if (interruptedKeepAliveResume)
|
|
316
|
+
return null;
|
|
317
|
+
// A later envelope proves the stop was followed by another turn even when
|
|
318
|
+
// neither capped history has a matching entry. Report the restart as
|
|
319
|
+
// untimed instead of using the lane's mutable current state as a proxy.
|
|
320
|
+
return nextEnvelopeMs === undefined ? null : { at: null, trigger: "unknown", caller: null };
|
|
320
321
|
}
|
|
321
322
|
function classifyVerdict(input) {
|
|
322
323
|
const { dueAt, restart, lane, nowMs } = input;
|
|
324
|
+
if (restart?.trigger === "unknown" && laneWasKeepAliveOffAtStop(lane, input.stoppedAt))
|
|
325
|
+
return "keep_alive_off";
|
|
323
326
|
if (restart) {
|
|
324
327
|
if (restart.at === null)
|
|
325
328
|
return "restarted_untimed";
|
|
@@ -356,7 +359,7 @@ function classifyVerdict(input) {
|
|
|
356
359
|
}
|
|
357
360
|
if (laneWasKeepAliveOffAtStop(lane, input.stoppedAt))
|
|
358
361
|
return "keep_alive_off";
|
|
359
|
-
if (
|
|
362
|
+
if (nowMs > dueAt + KEEP_ALIVE_NOT_RESTARTED_GRACE_MS)
|
|
360
363
|
return "not_restarted";
|
|
361
364
|
return "waiting";
|
|
362
365
|
}
|
|
@@ -413,8 +416,12 @@ export function buildKeepAliveAudit(input) {
|
|
|
413
416
|
rows.sort((a, b) => a.stopped_at.localeCompare(b.stopped_at) || a.lane.localeCompare(b.lane));
|
|
414
417
|
const byVerdict = Object.fromEntries(VERDICTS.map((verdict) => [verdict, 0]));
|
|
415
418
|
let lateOverTenMinutes = 0;
|
|
419
|
+
let lateRestartsAfterWindow = 0;
|
|
416
420
|
for (const row of rows) {
|
|
417
421
|
byVerdict[row.verdict] += 1;
|
|
422
|
+
if (row.restart !== null && row.restart.at !== null && Date.parse(row.restart.at) > Date.parse(row.due_at) + KEEP_ALIVE_NOT_RESTARTED_GRACE_MS) {
|
|
423
|
+
lateRestartsAfterWindow += 1;
|
|
424
|
+
}
|
|
418
425
|
if (row.verdict === "late" && row.restart !== null && row.restart.at !== null && Date.parse(row.restart.at) - Date.parse(row.due_at) > LATE_KPI_THRESHOLD_MS) {
|
|
419
426
|
lateOverTenMinutes += 1;
|
|
420
427
|
}
|
|
@@ -427,6 +434,7 @@ export function buildKeepAliveAudit(input) {
|
|
|
427
434
|
by_verdict: byVerdict,
|
|
428
435
|
not_restarted: byVerdict.not_restarted,
|
|
429
436
|
restarted_untimed: byVerdict.restarted_untimed,
|
|
437
|
+
late_restarts_after_window: lateRestartsAfterWindow,
|
|
430
438
|
late_over_10m: lateOverTenMinutes,
|
|
431
439
|
early_wake_by_others: byVerdict.early_wake,
|
|
432
440
|
self_wake: byVerdict.self_wake,
|
|
@@ -455,7 +463,7 @@ export function formatKeepAliveAuditText(result) {
|
|
|
455
463
|
...values.map(tableLine),
|
|
456
464
|
"",
|
|
457
465
|
`Since ${result.since}; ${counts}`,
|
|
458
|
-
`not_restarted=${result.summary.not_restarted}, late_over_10m=${result.summary.late_over_10m}, early_wake_by_others=${result.summary.early_wake_by_others}, self_wake=${result.summary.self_wake}`,
|
|
466
|
+
`not_restarted=${result.summary.not_restarted}, late_restarts_after_window=${result.summary.late_restarts_after_window}, late_over_10m=${result.summary.late_over_10m}, early_wake_by_others=${result.summary.early_wake_by_others}, self_wake=${result.summary.self_wake}`,
|
|
459
467
|
`Stopped and not restarted on their own: ${result.summary.stopped_and_not_restarted_on_their_own}`,
|
|
460
468
|
...result.errors.map((error) => `Error ${error.lane} (${error.error}) at ${error.path}: ${error.message}`),
|
|
461
469
|
].join("\n");
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type FirstSyncStepOutcome } from "../lib/first-sync-next-step.js";
|
|
2
2
|
export { CLI_FIRST_SYNC_NEXT_STEP_FLAG, FIRST_SYNC_CONFIRMATION_COPY, FIRST_SYNC_NEXT_STEP_COPY, claimFirstSyncStepMarker, firstSyncStepMarkerPaths, firstSyncStepTelemetryProperties, isHumanInteractiveCliSession, isWorkBearingSyncResult, maybeShowFirstSyncNextStep, recordFirstWorkBearingSync, shouldConfirmFirstWorkBearingSync, shouldShowFirstSyncNextStep, takeFirstSyncStepOutcomes, type FirstSyncStepOutcome, } from "../lib/first-sync-next-step.js";
|
|
3
3
|
export declare const TELEMETRY_TIMEOUT_MS = 1200;
|
|
4
|
+
export declare const CLI_SESSION_STARTED_BUDGET_MAX_EVENTS = 30;
|
|
5
|
+
export declare const CLI_SESSION_STARTED_BUDGET_WINDOW_MS = 60000;
|
|
4
6
|
/**
|
|
5
7
|
* Exact stderr line printed once on the first signed-in TTY run that is not
|
|
6
8
|
* `--json`. Keep this notice and its marker stable for session telemetry;
|
|
@@ -9,6 +9,8 @@ import { vaultApiFetch } from "./vault-api.js";
|
|
|
9
9
|
import { firstSyncStepTelemetryProperties, isHumanInteractiveCliSession, } from "../lib/first-sync-next-step.js";
|
|
10
10
|
export { CLI_FIRST_SYNC_NEXT_STEP_FLAG, FIRST_SYNC_CONFIRMATION_COPY, FIRST_SYNC_NEXT_STEP_COPY, claimFirstSyncStepMarker, firstSyncStepMarkerPaths, firstSyncStepTelemetryProperties, isHumanInteractiveCliSession, isWorkBearingSyncResult, maybeShowFirstSyncNextStep, recordFirstWorkBearingSync, shouldConfirmFirstWorkBearingSync, shouldShowFirstSyncNextStep, takeFirstSyncStepOutcomes, } from "../lib/first-sync-next-step.js";
|
|
11
11
|
export const TELEMETRY_TIMEOUT_MS = 1_200;
|
|
12
|
+
export const CLI_SESSION_STARTED_BUDGET_MAX_EVENTS = 30;
|
|
13
|
+
export const CLI_SESSION_STARTED_BUDGET_WINDOW_MS = 60_000;
|
|
12
14
|
/**
|
|
13
15
|
* Exact stderr line printed once on the first signed-in TTY run that is not
|
|
14
16
|
* `--json`. Keep this notice and its marker stable for session telemetry;
|
|
@@ -25,6 +27,8 @@ const DISCLOSURE_MARKER = "cli-telemetry-disclosed";
|
|
|
25
27
|
const FIRST_SYNC_DISCLOSURE_MARKER = "cli-telemetry-disclosed-first-sync-v1";
|
|
26
28
|
const INVITE_NUDGE_DISCLOSURE_MARKER = "cli-telemetry-disclosed-invite-nudge-v1";
|
|
27
29
|
const FEATURE_USED_DISCLOSURE_MARKER = "cli-telemetry-disclosed-feature-used-v1";
|
|
30
|
+
const SESSION_STARTED_BUDGET_FILE = "cli-session-started-budget.json";
|
|
31
|
+
const SESSION_STARTED_BUDGET_LOCK_STALE_MS = 10_000;
|
|
28
32
|
let cliSessionStartedPromise;
|
|
29
33
|
const pendingPlanLimitInviteTeammateExposures = [];
|
|
30
34
|
const pendingCliInviteNudgeEvents = [];
|
|
@@ -236,6 +240,84 @@ function hqConfigDir() {
|
|
|
236
240
|
const home = process.env.HOME?.trim() || os.homedir();
|
|
237
241
|
return path.join(home, ".hq");
|
|
238
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Limit only session-start telemetry per machine. Local state is best effort:
|
|
245
|
+
* damaged or unwritable state must never suppress the event or affect a command.
|
|
246
|
+
*/
|
|
247
|
+
function allowCliSessionStartedBudget(now = Date.now()) {
|
|
248
|
+
const stampPath = path.join(hqConfigDir(), SESSION_STARTED_BUDGET_FILE);
|
|
249
|
+
const lockPath = `${stampPath}.lock`;
|
|
250
|
+
let lockFd;
|
|
251
|
+
try {
|
|
252
|
+
lockFd = fs.openSync(lockPath, "wx", 0o600);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (!isAlreadyExistsError(error)) {
|
|
256
|
+
logSessionStartedBudgetError("acquire", error);
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
const lockAgeMs = Date.now() - fs.statSync(lockPath).mtimeMs;
|
|
261
|
+
if (lockAgeMs <= SESSION_STARTED_BUDGET_LOCK_STALE_MS)
|
|
262
|
+
return false;
|
|
263
|
+
fs.unlinkSync(lockPath);
|
|
264
|
+
lockFd = fs.openSync(lockPath, "wx", 0o600);
|
|
265
|
+
}
|
|
266
|
+
catch (retryError) {
|
|
267
|
+
if (isAlreadyExistsError(retryError))
|
|
268
|
+
return false;
|
|
269
|
+
logSessionStartedBudgetError("recover", retryError);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
return recordCliSessionStartedBudget(stampPath, now);
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
try {
|
|
278
|
+
fs.closeSync(lockFd);
|
|
279
|
+
fs.unlinkSync(lockPath);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
logSessionStartedBudgetError("release", error);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function recordCliSessionStartedBudget(stampPath, now) {
|
|
287
|
+
let recent = [];
|
|
288
|
+
if (fs.existsSync(stampPath)) {
|
|
289
|
+
try {
|
|
290
|
+
const parsed = JSON.parse(fs.readFileSync(stampPath, "utf8"));
|
|
291
|
+
if (Array.isArray(parsed) &&
|
|
292
|
+
parsed.every((value) => typeof value === "number" && Number.isFinite(value))) {
|
|
293
|
+
recent = parsed.filter((timestamp) => timestamp <= now && now - timestamp < CLI_SESSION_STARTED_BUDGET_WINDOW_MS);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
console.error("hq: Invalid CLI session telemetry budget stamp; allowing telemetry.");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
logSessionStartedBudgetError("read", error);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (recent.length >= CLI_SESSION_STARTED_BUDGET_MAX_EVENTS)
|
|
304
|
+
return false;
|
|
305
|
+
try {
|
|
306
|
+
fs.writeFileSync(stampPath, JSON.stringify([...recent, now]), { mode: 0o600 });
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
logSessionStartedBudgetError("write", error);
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
function isAlreadyExistsError(error) {
|
|
315
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
|
|
316
|
+
}
|
|
317
|
+
function logSessionStartedBudgetError(operation, error) {
|
|
318
|
+
const errorClass = error instanceof Error ? error.name : "unknown";
|
|
319
|
+
console.error(`hq: Could not ${operation} CLI session telemetry budget stamp (${errorClass}); allowing telemetry.`);
|
|
320
|
+
}
|
|
239
321
|
export function telemetryDisclosureMarkerPath() {
|
|
240
322
|
return path.join(hqConfigDir(), DISCLOSURE_MARKER);
|
|
241
323
|
}
|
|
@@ -323,6 +405,8 @@ async function emitCachedCliSessionStarted(timeoutMs) {
|
|
|
323
405
|
const token = isMachineIdentity() ? cached.idToken : cached.accessToken;
|
|
324
406
|
if (!token)
|
|
325
407
|
return;
|
|
408
|
+
if (!allowCliSessionStartedBudget())
|
|
409
|
+
return;
|
|
326
410
|
const disclosed = maybeDiscloseCliTelemetry();
|
|
327
411
|
const consentBasis = disclosed ? "disclosed" : "no-consent";
|
|
328
412
|
const controller = new AbortController();
|
|
@@ -842,6 +842,7 @@ const SELF_UPDATE_ERROR_CLASSES = new Set([
|
|
|
842
842
|
"MODULE_NOT_FOUND",
|
|
843
843
|
"ENOENT",
|
|
844
844
|
"EACCES",
|
|
845
|
+
"EBUSY",
|
|
845
846
|
"EEXIST",
|
|
846
847
|
"E404",
|
|
847
848
|
"EAI_AGAIN",
|
|
@@ -861,6 +862,8 @@ function selfUpdateErrorReason(code, failureKind) {
|
|
|
861
862
|
case "EACCES":
|
|
862
863
|
case "EPERM":
|
|
863
864
|
return "permission_denied";
|
|
865
|
+
case "EBUSY":
|
|
866
|
+
return "resource_busy";
|
|
864
867
|
case "EEXIST":
|
|
865
868
|
return "file_collision";
|
|
866
869
|
case "E404":
|
|
@@ -507,7 +507,7 @@ export function captureNonWritableNpmPrefixNotice(input) {
|
|
|
507
507
|
if (!claimed)
|
|
508
508
|
return Promise.resolve();
|
|
509
509
|
Sentry.captureMessage("Self-update deferred: npm prefix is not writable", {
|
|
510
|
-
level: "
|
|
510
|
+
level: "info",
|
|
511
511
|
fingerprint: ["self-update", "npm-prefix-not-writable"],
|
|
512
512
|
tags: {
|
|
513
513
|
from_version: input.fromVersion,
|
|
@@ -754,6 +754,7 @@ const INSTALL_DETAIL_MAX_CHARS = 400;
|
|
|
754
754
|
const KNOWN_NPM_UPDATE_ERROR_CODES = new Set([
|
|
755
755
|
"E404",
|
|
756
756
|
"EACCES",
|
|
757
|
+
"EBUSY",
|
|
757
758
|
"EAI_AGAIN",
|
|
758
759
|
"ECONNREFUSED",
|
|
759
760
|
"ECONNRESET",
|