@indigoai-us/hq-cli 5.345.5 → 5.345.7

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,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.7] — 2026-10-05
6
+ - Retry a just-published hq-cli version when npm briefly reports `notarget`.
7
+ - Wait for an active sync pass to finish before restarting sync after a CLI update.
8
+ - Add bounded context to captured missing-module errors targeting hq-cli's own dist output.
9
+ - Recover interrupted agent inbox polls without duplicating mirrored journal entries.
10
+ - Record the actual launch time, trigger, and caller for resumed lane workers even when legacy launch history is missing.
11
+
12
+ ## [5.345.6] — 2026-10-05
13
+ - `hq mesh context default set <slug> --count-usage` now works for people signed in with a normal HQ account. It previously refused with "the signed-in person could not be determined". An opt-in saved by 5.344.0 is ignored; run the command again to opt in.
14
+ - `hq daemon sync status` now identifies the executing runner and derives its hq-cloud version from the owner status or runner package when needed.
15
+
5
16
  ## [5.345.5] — 2026-10-05
6
17
  - `hq daemon sync status` now identifies the executing runner and derives its hq-cloud version from the owner status or runner package when needed.
7
18
 
@@ -9,7 +9,7 @@ import { cognitoConfigForLoginProvider } from '../utils/login-provider.js';
9
9
  import * as readline from "node:readline/promises";
10
10
  import { listActiveMembershipCompanies } from "../lib/mesh/api.js";
11
11
  import { retainUsageAttributionForPerson, setDefaultCompany } from "../lib/work-context/config.js";
12
- import { personUidFromTokens } from "../lib/work-context/person-identity.js";
12
+ import { cognitoSubFromTokens } from "../lib/work-context/person-identity.js";
13
13
  import { ensureDefaultCompany } from "../lib/work-context/default-company.js";
14
14
  import { workContextRoot } from "../lib/work-context/paths.js";
15
15
  function peekIdToken(idToken) {
@@ -66,7 +66,7 @@ export function registerLoginCommand(program) {
66
66
  try {
67
67
  // The usage-attribution opt-in belongs to the person who gave it. A
68
68
  // different (or unknown) account signing in on this OS user drops it.
69
- retainUsageAttributionForPerson(personUidFromTokens(loadCachedTokens()), {
69
+ retainUsageAttributionForPerson(cognitoSubFromTokens(loadCachedTokens()), {
70
70
  root: workContextRoot(),
71
71
  });
72
72
  }
@@ -20,7 +20,7 @@ import { buildPreflightFixtures } from "../lib/work-context/preflight-contract.j
20
20
  import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
21
21
  import { clearDefaultCompany, effectiveUsageAttribution, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, setUsageAttribution, } from "../lib/work-context/config.js";
22
22
  import { ensureDefaultCompany } from "../lib/work-context/default-company.js";
23
- import { personUidFromTokens } from "../lib/work-context/person-identity.js";
23
+ import { cognitoSubFromTokens } from "../lib/work-context/person-identity.js";
24
24
  import { DefaultCompanyUnavailableError, WorkContextError, } from "../lib/work-context/errors.js";
25
25
  import { isValidSessionId } from "../lib/mesh/live/session-identity.js";
26
26
  import { flushSessionEvents } from "../lib/mesh/live/flush.js";
@@ -1212,17 +1212,17 @@ export async function askYesNo(question, io = {}) {
1212
1212
  rl.close();
1213
1213
  }
1214
1214
  }
1215
- /** The signed-in person's prs_ uid from the cached Cognito session, or null. */
1216
- function currentPersonUid() {
1215
+ /** The signed-in person's Cognito sub from the cached session, or null. */
1216
+ function currentCognitoSub() {
1217
1217
  try {
1218
- return personUidFromTokens(loadCachedTokens());
1218
+ return cognitoSubFromTokens(loadCachedTokens());
1219
1219
  }
1220
1220
  catch {
1221
1221
  return null;
1222
1222
  }
1223
1223
  }
1224
- function usageAttributionJson(cfg, personUid) {
1225
- const effective = effectiveUsageAttribution(cfg, personUid);
1224
+ function usageAttributionJson(cfg, cognitoSub) {
1225
+ const effective = effectiveUsageAttribution(cfg, cognitoSub);
1226
1226
  return { countUsage: effective !== null, usageAttribution: effective };
1227
1227
  }
1228
1228
  async function runContextDefaultGet(opts) {
@@ -1244,7 +1244,7 @@ async function runContextDefaultGet(opts) {
1244
1244
  needsChoice: !current && !disabled && memberships.length > 1,
1245
1245
  memberships,
1246
1246
  repairHeldWithDefault: cfg.repairHeldWithDefault === true,
1247
- ...usageAttributionJson(cfg, currentPersonUid()),
1247
+ ...usageAttributionJson(cfg, currentCognitoSub()),
1248
1248
  }, null, 2));
1249
1249
  return;
1250
1250
  }
@@ -1257,7 +1257,7 @@ async function runContextDefaultGet(opts) {
1257
1257
  return;
1258
1258
  }
1259
1259
  console.log(`Default company: ${current.slug}${current.uid ? ` (${current.uid})` : ""} enabled=${current.enabled} source=${source}`);
1260
- console.log(effectiveUsageAttribution(cfg, currentPersonUid())
1260
+ console.log(effectiveUsageAttribution(cfg, currentCognitoSub())
1261
1261
  ? usageCountedLine(current.slug)
1262
1262
  : usageNotCountedLine(current.slug));
1263
1263
  }
@@ -1269,8 +1269,8 @@ async function runContextDefaultSet(slug, opts, promptDeps = {}) {
1269
1269
  : opts.countUsage === false
1270
1270
  ? "opt-out"
1271
1271
  : "keep-if-same";
1272
- const personUid = currentPersonUid();
1273
- const priorOptIn = effectiveUsageAttribution(readDeviceConfig({ root }), personUid);
1272
+ const cognitoSub = currentCognitoSub();
1273
+ const priorOptIn = effectiveUsageAttribution(readDeviceConfig({ root }), cognitoSub);
1274
1274
  let token;
1275
1275
  try {
1276
1276
  token = await requireToken();
@@ -1311,7 +1311,7 @@ async function runContextDefaultSet(slug, opts, promptDeps = {}) {
1311
1311
  root,
1312
1312
  migrationCapability,
1313
1313
  usageAttribution: usageChoice,
1314
- personUid,
1314
+ cognitoSub,
1315
1315
  validateMembership: async (candidate) => {
1316
1316
  if (!token) {
1317
1317
  throw new DefaultCompanyUnavailableError(`Cannot verify membership for company "${candidate}" (no Cognito session; offline or not logged in)`);
@@ -1340,7 +1340,7 @@ async function runContextDefaultSet(slug, opts, promptDeps = {}) {
1340
1340
  ok: true,
1341
1341
  config: configured,
1342
1342
  migrationWarning,
1343
- ...usageAttributionJson(configured, personUid),
1343
+ ...usageAttributionJson(configured, cognitoSub),
1344
1344
  }, null, 2));
1345
1345
  return;
1346
1346
  }
@@ -1352,8 +1352,8 @@ async function runContextDefaultSet(slug, opts, promptDeps = {}) {
1352
1352
  (() => isInteractiveTerminal()))();
1353
1353
  if (usageChoice === "keep-if-same" &&
1354
1354
  interactive &&
1355
- personUid &&
1356
- !effectiveUsageAttribution(configured, personUid) &&
1355
+ cognitoSub &&
1356
+ !effectiveUsageAttribution(configured, cognitoSub) &&
1357
1357
  stored?.uid?.startsWith("cmp_")) {
1358
1358
  console.error(usageOptInExplainer(company));
1359
1359
  let yes = false;
@@ -1364,9 +1364,9 @@ async function runContextDefaultSet(slug, opts, promptDeps = {}) {
1364
1364
  yes = false;
1365
1365
  }
1366
1366
  if (yes)
1367
- configured = setUsageAttribution({ companyUid: stored.uid, personUid }, { root });
1367
+ configured = setUsageAttribution({ companyUid: stored.uid, cognitoSub }, { root });
1368
1368
  }
1369
- console.log(effectiveUsageAttribution(configured, personUid)
1369
+ console.log(effectiveUsageAttribution(configured, cognitoSub)
1370
1370
  ? usageCountedLine(company)
1371
1371
  : usageNotCountedLine(company));
1372
1372
  if (migrationWarning)
@@ -1503,7 +1503,7 @@ async function runContextDefaultClear(opts) {
1503
1503
  const hadOptIn = readDeviceConfig({ root }).usageAttribution !== undefined;
1504
1504
  const cfg = clearDefaultCompany({ root });
1505
1505
  if (opts.json) {
1506
- console.log(JSON.stringify({ ok: true, config: cfg, ...usageAttributionJson(cfg, currentPersonUid()) }, null, 2));
1506
+ console.log(JSON.stringify({ ok: true, config: cfg, ...usageAttributionJson(cfg, currentCognitoSub()) }, null, 2));
1507
1507
  return;
1508
1508
  }
1509
1509
  console.log("Default company cleared.");
@@ -54,10 +54,34 @@ export function writeSeenIds(paths, seen) {
54
54
  const list = [...seen].slice(-MAX_SEEN_IDS);
55
55
  writePrivateFile(path.join(paths.inboxDir, SEEN_IDS_NAME), JSON.stringify(list), 0o600);
56
56
  }
57
+ function inboxJournalContains(paths, id) {
58
+ const journal = path.join(paths.inboxDir, INBOX_JSONL_NAME);
59
+ if (!fs.existsSync(journal))
60
+ return false;
61
+ for (const line of fs.readFileSync(journal, "utf8").split("\n")) {
62
+ if (!line)
63
+ continue;
64
+ let entry;
65
+ try {
66
+ entry = JSON.parse(line);
67
+ }
68
+ catch {
69
+ // An interrupted append can leave one malformed final record; it does
70
+ // not prove that this item made it into the durable journal.
71
+ continue;
72
+ }
73
+ if (entry && typeof entry === "object" && entry.id === id)
74
+ return true;
75
+ }
76
+ return false;
77
+ }
57
78
  export function mirrorInboxItem(paths, item) {
58
79
  const dest = path.join(paths.inboxDir, `${item.id}.json`);
80
+ const recoveringInterruptedMirror = fs.existsSync(dest);
59
81
  writePrivateFile(dest, `${JSON.stringify(item.raw, null, 2)}\n`, 0o600);
60
- fs.appendFileSync(path.join(paths.inboxDir, INBOX_JSONL_NAME), `${JSON.stringify({ id: item.id, mirroredAt: new Date().toISOString(), ...item.raw })}\n`, { mode: 0o600 });
82
+ if (!recoveringInterruptedMirror || !inboxJournalContains(paths, item.id)) {
83
+ fs.appendFileSync(path.join(paths.inboxDir, INBOX_JSONL_NAME), `${JSON.stringify({ id: item.id, mirroredAt: new Date().toISOString(), ...item.raw })}\n`, { mode: 0o600 });
84
+ }
61
85
  return dest;
62
86
  }
63
87
  export const INBOX_ERROR_BODY_MAX = 300;
@@ -148,7 +172,6 @@ export async function pollInboxOnce(deps) {
148
172
  const fresh = extractInboxItems(res.body).filter((i) => !seen.has(i.id));
149
173
  for (const item of fresh) {
150
174
  mirrorInboxItem(deps.paths, item);
151
- seen.add(item.id);
152
175
  if (deps.ack) {
153
176
  try {
154
177
  const status = await ackItem(token, item.id);
@@ -159,9 +182,12 @@ export async function pollInboxOnce(deps) {
159
182
  deps.log("warn", `ack ${item.id} failed: ${err instanceof Error ? err.message : String(err)}`);
160
183
  }
161
184
  }
185
+ // Persist each completed item before starting another one. If shutdown
186
+ // interrupts a later ack or mirror, earlier items will not be replayed.
187
+ seen.add(item.id);
188
+ writeSeenIds(deps.paths, seen);
162
189
  }
163
190
  if (fresh.length > 0) {
164
- writeSeenIds(deps.paths, seen);
165
191
  deps.log("info", `inbox mirrored ${fresh.length} item(s)`);
166
192
  try {
167
193
  await warmCache(token, deps.agentUid);
@@ -27,6 +27,7 @@ import type { ControlRequest } from "./requests.js";
27
27
  import { syncPassResult } from "./sync-runner.js";
28
28
  export declare const STOP_ESCALATION_MS = 10000;
29
29
  export declare const CLI_UPDATE_SYNC_IDLE_MAX_MS: number;
30
+ export declare const CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS = 60000;
30
31
  /** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
31
32
  export declare const HANDOFF_STAGGER_MS = 15000;
32
33
  /** How long a draining unit may take to finish its work before it gets SIGTERM. */
@@ -28,6 +28,7 @@ const BACKOFF_MAX_MS = 60_000;
28
28
  const HEALTHY_RUN_MS = 60_000;
29
29
  export const STOP_ESCALATION_MS = 10_000;
30
30
  export const CLI_UPDATE_SYNC_IDLE_MAX_MS = 10 * 60_000;
31
+ export const CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS = 60_000;
31
32
  const MESH_OWNER_RECHECK_MS = 15_000;
32
33
  const MESH_OWNER_LOG_INTERVAL_MS = 10 * 60_000;
33
34
  /** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
@@ -92,6 +93,7 @@ export function createSupervisor(deps) {
92
93
  let deferredCliRestart = false;
93
94
  let deferredCliRestartTimer;
94
95
  let cliRestartInProgress = false;
96
+ let pendingSyncCommandRestart;
95
97
  const snapshot = () => ({
96
98
  version: 1,
97
99
  pid: deps.pid,
@@ -125,15 +127,26 @@ export function createSupervisor(deps) {
125
127
  clearT(deferredCliRestartTimer);
126
128
  deferredCliRestartTimer = undefined;
127
129
  };
128
- const runDeferredCliRestart = () => {
130
+ const clearPendingSyncCommandRestart = (slot) => {
131
+ if (!pendingSyncCommandRestart || (slot && pendingSyncCommandRestart.slot !== slot))
132
+ return;
133
+ if (pendingSyncCommandRestart.timer !== undefined)
134
+ clearT(pendingSyncCommandRestart.timer);
135
+ pendingSyncCommandRestart = undefined;
136
+ };
137
+ const runDeferredCliRestart = (idleTimeout = false) => {
129
138
  if (!deferredCliRestart)
130
139
  return;
140
+ clearPendingSyncCommandRestart();
131
141
  clearDeferredCliRestart();
132
142
  cliRestartInProgress = true;
133
143
  const sync = [...slots.values()].find((slot) => slot.plan.service === "sync" && slot.child);
134
- if (sync)
135
- terminate(sync);
136
- deps.log("info", "sync runner is between passes; restarting daemon onto the updated CLI");
144
+ if (sync) {
145
+ terminate(sync, undefined, false, idleTimeout ? CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS : STOP_ESCALATION_MS);
146
+ }
147
+ deps.log(idleTimeout ? "warn" : "info", idleTimeout
148
+ ? "sync pass remained active for 10 minutes; restarting daemon onto the updated CLI"
149
+ : "sync runner is between passes; restarting daemon onto the updated CLI");
137
150
  const releaseHold = () => {
138
151
  cliRestartInProgress = false;
139
152
  const currentSync = [...slots.values()].find((slot) => slot.plan.service === "sync");
@@ -163,6 +176,17 @@ export function createSupervisor(deps) {
163
176
  releaseHold();
164
177
  }
165
178
  };
179
+ const finishPendingSyncCommandRestart = (idleTimeout = false) => {
180
+ const pending = pendingSyncCommandRestart;
181
+ if (!pending)
182
+ return;
183
+ clearPendingSyncCommandRestart(pending.slot);
184
+ if (deferredCliRestart) {
185
+ runDeferredCliRestart(idleTimeout);
186
+ return;
187
+ }
188
+ pending.onFinish(idleTimeout);
189
+ };
166
190
  const requestCliUpdateRestart = () => {
167
191
  if (!deps.restartDaemon)
168
192
  return;
@@ -173,7 +197,10 @@ export function createSupervisor(deps) {
173
197
  }
174
198
  if (sync.syncPassActive === false) {
175
199
  deferredCliRestart = true;
176
- runDeferredCliRestart();
200
+ if (pendingSyncCommandRestart?.slot === sync)
201
+ finishPendingSyncCommandRestart();
202
+ else
203
+ runDeferredCliRestart();
177
204
  return;
178
205
  }
179
206
  if (deferredCliRestart) {
@@ -182,7 +209,25 @@ export function createSupervisor(deps) {
182
209
  }
183
210
  deferredCliRestart = true;
184
211
  deps.log("info", "deferring CLI-update restart until sync is idle (maximum 10 minutes)");
185
- deferredCliRestartTimer = setT(runDeferredCliRestart, CLI_UPDATE_SYNC_IDLE_MAX_MS);
212
+ if (pendingSyncCommandRestart?.slot === sync)
213
+ return;
214
+ deferredCliRestartTimer = setT(() => runDeferredCliRestart(true), CLI_UPDATE_SYNC_IDLE_MAX_MS);
215
+ };
216
+ const deferSyncCommandRestart = (slot, onFinish) => {
217
+ if (slot.syncPassActive === false) {
218
+ onFinish(false);
219
+ return;
220
+ }
221
+ if (pendingSyncCommandRestart?.slot === slot)
222
+ return;
223
+ clearPendingSyncCommandRestart();
224
+ const pending = { slot, onFinish };
225
+ pendingSyncCommandRestart = pending;
226
+ deps.log("info", `deferring restart of ${slot.plan.id} until its sync pass is idle (maximum 10 minutes)`);
227
+ // A pending CLI restart already owns the same 10-minute idle window.
228
+ if (!deferredCliRestart) {
229
+ pending.timer = setT(() => finishPendingSyncCommandRestart(true), CLI_UPDATE_SYNC_IDLE_MAX_MS);
230
+ }
186
231
  };
187
232
  const setBlocked = (slot) => {
188
233
  const block = slot.plan.blocked;
@@ -204,7 +249,8 @@ export function createSupervisor(deps) {
204
249
  * `drain`, a unit that has a drain signal gets it first and SIGTERM only
205
250
  * after HANDOFF_DRAIN_MAX_MS; SIGKILL follows SIGTERM after STOP_ESCALATION_MS.
206
251
  */
207
- const terminate = (slot, then, drain = false) => {
252
+ const terminate = (slot, then, drain = false, killGraceMs = STOP_ESCALATION_MS) => {
253
+ clearPendingSyncCommandRestart(slot);
208
254
  clearTimer(slot);
209
255
  clearHeartbeatTimer(slot);
210
256
  const child = slot.child;
@@ -227,9 +273,9 @@ export function createSupervisor(deps) {
227
273
  slot.killTimer = undefined;
228
274
  if (slot.child !== child)
229
275
  return;
230
- deps.log("warn", `${slot.plan.id} did not exit within ${STOP_ESCALATION_MS / 1_000} s of SIGTERM; sending SIGKILL${lastProtocolHint(slot)}`);
276
+ deps.log("warn", `${slot.plan.id} did not exit within ${killGraceMs / 1_000} s of SIGTERM; sending SIGKILL${lastProtocolHint(slot)}`);
231
277
  signalChild(slot, child, "SIGKILL");
232
- }, STOP_ESCALATION_MS);
278
+ }, killGraceMs);
233
279
  };
234
280
  const drainSignal = drain ? slot.plan.drainSignal : undefined;
235
281
  if (drainSignal) {
@@ -309,8 +355,12 @@ export function createSupervisor(deps) {
309
355
  slot.syncPassActive = true;
310
356
  }
311
357
  }
312
- if (deferredCliRestart && slot.syncPassActive === false)
313
- runDeferredCliRestart();
358
+ if (slot.syncPassActive === false) {
359
+ if (pendingSyncCommandRestart?.slot === slot)
360
+ finishPendingSyncCommandRestart();
361
+ else if (deferredCliRestart)
362
+ runDeferredCliRestart();
363
+ }
314
364
  }
315
365
  const at = now();
316
366
  slot.state.lastHeartbeatAt = new Date(at).toISOString();
@@ -357,6 +407,11 @@ export function createSupervisor(deps) {
357
407
  if (slot.child !== child)
358
408
  return;
359
409
  slot.child = undefined;
410
+ if (pendingSyncCommandRestart?.slot === slot) {
411
+ clearPendingSyncCommandRestart(slot);
412
+ if (deferredCliRestart)
413
+ runDeferredCliRestart();
414
+ }
360
415
  clearHeartbeatTimer(slot);
361
416
  if (slot.killTimer !== undefined) {
362
417
  clearT(slot.killTimer);
@@ -652,6 +707,34 @@ export function createSupervisor(deps) {
652
707
  else
653
708
  activate(existing);
654
709
  }
710
+ else if (changed && cliRestartInProgress && plan.service === "sync" && existing.child) {
711
+ // The deferred CLI restart owns this stop; do not queue a second
712
+ // replacement through the adopted-unit handoff path.
713
+ terminate(existing);
714
+ }
715
+ else if (changed && plan.service === "sync" && existing.child) {
716
+ existing.attempts = 0;
717
+ existing.state.restarts = 0;
718
+ const replaceWhenIdle = () => {
719
+ if (adopted) {
720
+ deps.log("info", `will replace ${plan.id}: it runs an older CLI or command`);
721
+ handoffQueue.push(existing);
722
+ pumpHandoff();
723
+ }
724
+ else {
725
+ deps.log("info", `restarting ${plan.id}: its command changed`);
726
+ terminate(existing, () => activate(existing));
727
+ }
728
+ };
729
+ deferSyncCommandRestart(existing, (idleTimeout) => {
730
+ if (!idleTimeout) {
731
+ replaceWhenIdle();
732
+ return;
733
+ }
734
+ deps.log("warn", `${plan.id} sync pass did not become idle within 10 minutes; stopping it to apply the changed command`);
735
+ terminate(existing, () => activate(existing), false, CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS);
736
+ });
737
+ }
655
738
  else if (changed && adopted && existing.child) {
656
739
  deps.log("info", `will replace ${plan.id}: it runs an older CLI or command`);
657
740
  handoffQueue.push(existing);
@@ -878,6 +961,7 @@ export function createSupervisor(deps) {
878
961
  if (handoffTimer !== undefined)
879
962
  clearT(handoffTimer);
880
963
  clearDeferredCliRestart();
964
+ clearPendingSyncCommandRestart();
881
965
  const childOwners = new Map();
882
966
  for (const slot of slots.values()) {
883
967
  if (slot.child)
@@ -2421,6 +2421,8 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2421
2421
  ...(resumeHistoryCaller ? { caller: resumeHistoryCaller } : {}),
2422
2422
  }),
2423
2423
  }));
2424
+ }
2425
+ if (priorStartRecorded || resume !== undefined) {
2424
2426
  appendLaneWorkerResumeEvent({
2425
2427
  hqRoot,
2426
2428
  laneId: lane.lane_id,
@@ -24,8 +24,8 @@
24
24
  * for the destructive side effects (delete, tombstone, PUT sync-config).
25
25
  */
26
26
  import { type SyncJournal } from "@indigoai-us/hq-cloud";
27
- export type DirtyReason = "modified-after-sync" | "hash-mismatch" | "not-in-journal" | "stat-error";
28
- export interface NarrowFile {
27
+ type DirtyReason = "modified-after-sync" | "hash-mismatch" | "not-in-journal" | "stat-error";
28
+ interface NarrowFile {
29
29
  /**
30
30
  * COMPANY-RELATIVE path (e.g. `meetings/a.md`) — the canonical namespace
31
31
  * shared by the per-company journal keys, the vault S3 keys, and the
@@ -117,4 +117,5 @@ export declare function toPosixKey(p: string): string;
117
117
  export declare function formatNarrowPlanSummary(plan: NarrowPlan): string;
118
118
  /** Human-readable byte counts with a fixed unit ladder. */
119
119
  export declare function formatBytes(n: number): string;
120
+ export {};
120
121
  //# sourceMappingURL=local-tree-diff.d.ts.map
@@ -93,8 +93,6 @@ export declare function defaultSpawnWorker({ logPath, fallbackLogPath }: {
93
93
  }, io?: SpawnWorkerIo): number;
94
94
  /** Defaults used by the CLI; tests supply every nondeterministic dependency. */
95
95
  export declare function defaultBackgroundDependencies(hqRoot: string): BackgroundDependencies;
96
- /** Match the shell forwarder's hosted-agent markers before looking up qmd. */
97
- export declare function isHostedAgent(env?: NodeJS.ProcessEnv): boolean;
98
96
  export declare function installWorkerCleanup(cleanup: () => void, processEvents?: Pick<NodeJS.Process, 'once'>, exit?: (code: number) => void): void;
99
97
  /** Start a detached worker; this public entry never owns the qmd pipeline. */
100
98
  export declare function runBackgroundLauncher(dependencies: BackgroundDependencies): BackgroundResult;
@@ -129,7 +129,7 @@ export function defaultBackgroundDependencies(hqRoot) {
129
129
  };
130
130
  }
131
131
  /** Match the shell forwarder's hosted-agent markers before looking up qmd. */
132
- export function isHostedAgent(env = process.env) {
132
+ function isHostedAgent(env = process.env) {
133
133
  if (env.HQ_QMD_REINDEX_MODE === 'skip-agent' || env.HQ_QMD_REINDEX_MODE === 'skip')
134
134
  return true;
135
135
  if (env.HQ_AGENT_BOX && env.HQ_AGENT_BOX !== '0')
@@ -327,19 +327,6 @@ export type ResolveQmdBinOptions = {
327
327
  * independent of the dependency's own exports map.
328
328
  */
329
329
  export declare function packageLocalBin(): string | undefined;
330
- /**
331
- * Locate the bundled qmd's Node LAUNCHER — `@tobilu/qmd`'s `bin.qmd` entry
332
- * (`bin/qmd`, a `#!/usr/bin/env node` script) — by the same upward walk
333
- * {@link resolveQmdVersion} uses to find the package. Returns the absolute
334
- * launcher path only when both the manifest and the launcher file exist.
335
- *
336
- * This is the shell-free preferred resolution: invoking `node <launcher>`
337
- * bypasses the npm-generated `.cmd` shim entirely, so the Windows CVE-2024-27980
338
- * EINVAL never arises and no user-supplied search text reaches cmd.exe. A
339
- * missing/partial layout returns undefined so the caller falls back to the
340
- * `.bin` shim (which the shared spawn plan now makes spawnable on Windows too).
341
- */
342
- export declare function packageLocalNodeEntry(): string | undefined;
343
330
  /**
344
331
  * Return the pinned package version when qmd is supplied by this CLI.
345
332
  *
@@ -412,12 +399,6 @@ export type EnsureQmdStoreDirOptions = {
412
399
  export declare function ensureQmdStoreDir(env?: NodeJS.ProcessEnv, options?: EnsureQmdStoreDirOptions): void;
413
400
  /** Reset per-process probe/repair memoisation. Test-only. */
414
401
  export declare function __resetQmdProbeStateForTests(): void;
415
- /** Probe a resolved invocation, memoised per invocation; records the failure
416
- * detail under the qmd bin identity so repair can read it. */
417
- export declare function isUsableInvocation(invocation: QmdInvocation, options?: {
418
- platform?: NodeJS.Platform;
419
- spawn?: QmdSpawn;
420
- }): boolean;
421
402
  /** Backwards-compatible string form: probe a bare bin path shell-free (or via
422
403
  * the shell plan when it is a Windows `.cmd`/`.bat`). */
423
404
  export declare function isUsableQmd(bin: string): boolean;
@@ -332,7 +332,7 @@ export function packageLocalBin() {
332
332
  * missing/partial layout returns undefined so the caller falls back to the
333
333
  * `.bin` shim (which the shared spawn plan now makes spawnable on Windows too).
334
334
  */
335
- export function packageLocalNodeEntry() {
335
+ function packageLocalNodeEntry() {
336
336
  let directory = path.dirname(fileURLToPath(import.meta.url));
337
337
  for (let depth = 0; depth < 10; depth++) {
338
338
  const manifest = path.join(directory, 'node_modules', '@tobilu', 'qmd', 'package.json');
@@ -644,7 +644,7 @@ function probeQmd(invocation, options = {}) {
644
644
  }
645
645
  /** Probe a resolved invocation, memoised per invocation; records the failure
646
646
  * detail under the qmd bin identity so repair can read it. */
647
- export function isUsableInvocation(invocation, options = {}) {
647
+ function isUsableInvocation(invocation, options = {}) {
648
648
  const key = invocationKey(invocation);
649
649
  const cached = usableQmdCache.get(key);
650
650
  if (cached !== undefined)
@@ -56,15 +56,16 @@ export interface UsageAttributionOptIn {
56
56
  mode: "default-company";
57
57
  companyUid: string;
58
58
  /**
59
- * The signed-in person who consented: the `custom:entityUid` claim (prs_...)
60
- * of the cached Cognito person session. Consent never carries to another
61
- * account on the same OS user.
59
+ * The signed-in person who consented: the Cognito `sub` claim of the cached
60
+ * person session. Consent never carries to another account on the same OS
61
+ * user. Older records that carry only `personUid` are treated as not opted
62
+ * in (fail closed).
62
63
  */
63
- personUid: string;
64
+ cognitoSub: string;
64
65
  optedInAt: string;
65
66
  }
66
- /** A person principal uid, as carried in the `custom:entityUid` token claim. */
67
- export declare function isPersonUid(value: unknown): value is string;
67
+ /** A Cognito `sub` claim: any non-empty string (after trimming). */
68
+ export declare function isCognitoSub(value: unknown): value is string;
68
69
  /** How `setDefaultCompany` treats the usage-attribution opt-in. */
69
70
  export type UsageAttributionChoice =
70
71
  /** Write an opt-in for the newly set company (requires its uid). */
@@ -113,10 +114,10 @@ export interface DeviceConfigDeps {
113
114
  */
114
115
  usageAttribution?: UsageAttributionChoice;
115
116
  /**
116
- * The signed-in person (prs_...). Required to write an opt-in; an existing
117
+ * The signed-in person (Cognito `sub`). Required to write an opt-in; an existing
117
118
  * opt-in is kept only when it was given by this person.
118
119
  */
119
- personUid?: string | null;
120
+ cognitoSub?: string | null;
120
121
  }
121
122
  /** Acquire an interprocess config lock; exported for direct lock regression tests. */
122
123
  export declare function acquireDeviceConfigLock(root: string, options?: {
@@ -130,7 +131,7 @@ export declare function sanitizeUsageAttribution(raw: unknown): UsageAttribution
130
131
  * default company, and given by the signed-in person. Mirrors the hq-cloud
131
132
  * reader. No signed-in person means no opt-in.
132
133
  */
133
- export declare function effectiveUsageAttribution(cfg: Pick<WorkContextDeviceConfig, "defaultCompany" | "usageAttribution">, personUid: string | null | undefined): UsageAttributionOptIn | null;
134
+ export declare function effectiveUsageAttribution(cfg: Pick<WorkContextDeviceConfig, "defaultCompany" | "usageAttribution">, cognitoSub: string | null | undefined): UsageAttributionOptIn | null;
134
135
  /** Injection points for assertSafeConfigPath (tests only). */
135
136
  export interface SafeConfigPathIo {
136
137
  platform?: NodeJS.Platform;
@@ -162,14 +163,14 @@ export declare function setDefaultCompany(slug: string, deps: DeviceConfigDeps):
162
163
  */
163
164
  export declare function setUsageAttribution(optIn: {
164
165
  companyUid: string;
165
- personUid: string;
166
+ cognitoSub: string;
166
167
  } | null, deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
167
168
  /**
168
- * Drop the usage-attribution opt-in unless it was given by `personUid`. Called
169
+ * Drop the usage-attribution opt-in unless it was given by `cognitoSub`. Called
169
170
  * on login (identity may have changed) and logout (null: always drop). A
170
171
  * config with no opt-in, or one already matching, is left untouched.
171
172
  */
172
- export declare function retainUsageAttributionForPerson(personUid: string | null | undefined, deps: Pick<DeviceConfigDeps, "root" | "now">): boolean;
173
+ export declare function retainUsageAttributionForPerson(cognitoSub: string | null | undefined, deps: Pick<DeviceConfigDeps, "root" | "now">): boolean;
173
174
  export declare function clearDefaultCompany(deps: DeviceConfigDeps): WorkContextDeviceConfig;
174
175
  /**
175
176
  * Read the persisted company mapping for a repo identity key (gap 4).
@@ -9,9 +9,9 @@ import { DefaultCompanyUnavailableError, UnsafeConfigPathError, WorkContextError
9
9
  import { workContextConfigPath } from "./paths.js";
10
10
  import { assertWindowsOwnerOnlyAcl } from "./windows-acl.js";
11
11
  const DEVICE_CONFIG_SCHEMA_VERSION = 1;
12
- /** A person principal uid, as carried in the `custom:entityUid` token claim. */
13
- export function isPersonUid(value) {
14
- return typeof value === "string" && /^prs_[A-Za-z0-9]+$/.test(value);
12
+ /** A Cognito `sub` claim: any non-empty string (after trimming). */
13
+ export function isCognitoSub(value) {
14
+ return typeof value === "string" && value.trim().length > 0;
15
15
  }
16
16
  const CONFIG_LOCK_TIMEOUT_MS = 5_000;
17
17
  const CONFIG_LOCK_RETRY_MS = 10;
@@ -158,24 +158,24 @@ export function sanitizeUsageAttribution(raw) {
158
158
  if (v.mode !== "default-company")
159
159
  return undefined;
160
160
  const companyUid = typeof v.companyUid === "string" ? v.companyUid.trim() : "";
161
- const personUid = typeof v.personUid === "string" ? v.personUid.trim() : "";
161
+ const cognitoSub = typeof v.cognitoSub === "string" ? v.cognitoSub.trim() : "";
162
162
  const optedInAt = typeof v.optedInAt === "string" ? v.optedInAt.trim() : "";
163
163
  if (!companyUid.startsWith("cmp_") ||
164
- !isPersonUid(personUid) ||
164
+ !isCognitoSub(cognitoSub) ||
165
165
  !optedInAt ||
166
166
  !Number.isFinite(Date.parse(optedInAt))) {
167
167
  return undefined;
168
168
  }
169
- return { mode: "default-company", companyUid, personUid, optedInAt };
169
+ return { mode: "default-company", companyUid, cognitoSub, optedInAt };
170
170
  }
171
171
  /**
172
172
  * The opt-in that is in effect: present, for the uid of the current enabled
173
173
  * default company, and given by the signed-in person. Mirrors the hq-cloud
174
174
  * reader. No signed-in person means no opt-in.
175
175
  */
176
- export function effectiveUsageAttribution(cfg, personUid) {
176
+ export function effectiveUsageAttribution(cfg, cognitoSub) {
177
177
  const opt = cfg.usageAttribution;
178
- if (!opt || !isPersonUid(personUid) || opt.personUid !== personUid)
178
+ if (!opt || !isCognitoSub(cognitoSub) || opt.cognitoSub !== cognitoSub)
179
179
  return null;
180
180
  const def = cfg.defaultCompany;
181
181
  if (!opt || !def || def.enabled !== true || def.source === "disabled")
@@ -332,27 +332,27 @@ export async function setDefaultCompany(slug, deps) {
332
332
  defaultCompany.uid = resolvedUid;
333
333
  const choice = deps.usageAttribution ?? "keep-if-same";
334
334
  let usageAttribution;
335
- const personUid = isPersonUid(deps.personUid) ? deps.personUid : undefined;
335
+ const cognitoSub = isCognitoSub(deps.cognitoSub) ? deps.cognitoSub : undefined;
336
336
  if (choice === "opt-in") {
337
337
  if (!resolvedUid?.startsWith("cmp_")) {
338
338
  throw new WorkContextError("UsageAttributionUnavailable", `Cannot count usage toward "${trimmed}": its company uid is unknown`);
339
339
  }
340
- if (!personUid) {
340
+ if (!cognitoSub) {
341
341
  throw new WorkContextError("UsageAttributionUnavailable", `Cannot count usage toward "${trimmed}": the signed-in person could not be determined`);
342
342
  }
343
343
  usageAttribution = {
344
344
  mode: "default-company",
345
345
  companyUid: resolvedUid,
346
- personUid,
346
+ cognitoSub,
347
347
  optedInAt: now().toISOString(),
348
348
  };
349
349
  }
350
350
  else if (choice === "keep-if-same") {
351
351
  usageAttribution =
352
352
  resolvedUid &&
353
- personUid &&
353
+ cognitoSub &&
354
354
  prior.usageAttribution?.companyUid === resolvedUid &&
355
- prior.usageAttribution.personUid === personUid
355
+ prior.usageAttribution.cognitoSub === cognitoSub
356
356
  ? prior.usageAttribution
357
357
  : undefined;
358
358
  }
@@ -388,13 +388,13 @@ export function setUsageAttribution(optIn, deps) {
388
388
  def.source !== "chosen" ||
389
389
  !def.uid?.startsWith("cmp_") ||
390
390
  def.uid !== optIn.companyUid ||
391
- !isPersonUid(optIn.personUid)) {
391
+ !isCognitoSub(optIn.cognitoSub)) {
392
392
  throw new WorkContextError("UsageAttributionUnavailable", "Usage can only be counted toward a default company you chose on this device");
393
393
  }
394
394
  usageAttribution = {
395
395
  mode: "default-company",
396
396
  companyUid: def.uid,
397
- personUid: optIn.personUid,
397
+ cognitoSub: optIn.cognitoSub,
398
398
  optedInAt: now().toISOString(),
399
399
  };
400
400
  }
@@ -413,11 +413,11 @@ export function setUsageAttribution(optIn, deps) {
413
413
  });
414
414
  }
415
415
  /**
416
- * Drop the usage-attribution opt-in unless it was given by `personUid`. Called
416
+ * Drop the usage-attribution opt-in unless it was given by `cognitoSub`. Called
417
417
  * on login (identity may have changed) and logout (null: always drop). A
418
418
  * config with no opt-in, or one already matching, is left untouched.
419
419
  */
420
- export function retainUsageAttributionForPerson(personUid, deps) {
420
+ export function retainUsageAttributionForPerson(cognitoSub, deps) {
421
421
  if (!fs.existsSync(workContextConfigPath(deps.root)))
422
422
  return false;
423
423
  return withDeviceConfigLock(deps.root, () => {
@@ -425,7 +425,7 @@ export function retainUsageAttributionForPerson(personUid, deps) {
425
425
  const opt = prior.usageAttribution;
426
426
  if (!opt)
427
427
  return false;
428
- if (isPersonUid(personUid) && opt.personUid === personUid)
428
+ if (isCognitoSub(cognitoSub) && opt.cognitoSub === cognitoSub)
429
429
  return false;
430
430
  const now = deps.now ?? (() => new Date());
431
431
  const { usageAttribution: _dropped, ...rest } = prior;
@@ -2,14 +2,18 @@
2
2
  * Signed-in person identity for the usage-attribution opt-in.
3
3
  *
4
4
  * Source: the cached Cognito person session (`~/.hq/cognito-tokens.json`,
5
- * read through `loadCachedTokens`). The person uid is the `custom:entityUid`
6
- * claim of the ID token (falling back to the access token), and only a
7
- * `prs_` value counts. Agent (`agt_`) or unknown identities return null, so
8
- * no opt-in is written or kept for them. Claims are read without
9
- * verification; this decides only whether local consent belongs to the
10
- * account that is signed in, never authorization.
5
+ * read through `loadCachedTokens`). The identity key is the Cognito `sub`
6
+ * claim of the ID token (falling back to the access token); it must be a
7
+ * non-empty string. Human sessions carry no `custom:entityUid` claim (hq-pro
8
+ * sets it only on agent and outpost machine identities), so it is not used.
9
+ * An agent or outpost session has its own Cognito user and therefore a
10
+ * different `sub`, so it could never match a person's opt-in; a token that
11
+ * names a machine identity (`custom:entityType` agent/outpost, or a
12
+ * non-`prs_` `custom:entityUid`) is refused outright. Claims are read
13
+ * without verification; this decides only whether local consent belongs to
14
+ * the account that is signed in, never authorization.
11
15
  */
12
- export declare function personUidFromTokens(tokens: {
16
+ export declare function cognitoSubFromTokens(tokens: {
13
17
  idToken?: string;
14
18
  accessToken?: string;
15
19
  } | null | undefined): string | null;
@@ -2,14 +2,18 @@
2
2
  * Signed-in person identity for the usage-attribution opt-in.
3
3
  *
4
4
  * Source: the cached Cognito person session (`~/.hq/cognito-tokens.json`,
5
- * read through `loadCachedTokens`). The person uid is the `custom:entityUid`
6
- * claim of the ID token (falling back to the access token), and only a
7
- * `prs_` value counts. Agent (`agt_`) or unknown identities return null, so
8
- * no opt-in is written or kept for them. Claims are read without
9
- * verification; this decides only whether local consent belongs to the
10
- * account that is signed in, never authorization.
5
+ * read through `loadCachedTokens`). The identity key is the Cognito `sub`
6
+ * claim of the ID token (falling back to the access token); it must be a
7
+ * non-empty string. Human sessions carry no `custom:entityUid` claim (hq-pro
8
+ * sets it only on agent and outpost machine identities), so it is not used.
9
+ * An agent or outpost session has its own Cognito user and therefore a
10
+ * different `sub`, so it could never match a person's opt-in; a token that
11
+ * names a machine identity (`custom:entityType` agent/outpost, or a
12
+ * non-`prs_` `custom:entityUid`) is refused outright. Claims are read
13
+ * without verification; this decides only whether local consent belongs to
14
+ * the account that is signed in, never authorization.
11
15
  */
12
- import { isPersonUid } from "./config.js";
16
+ import { isCognitoSub } from "./config.js";
13
17
  function claimsOf(token) {
14
18
  if (typeof token !== "string")
15
19
  return null;
@@ -26,13 +30,22 @@ function claimsOf(token) {
26
30
  return null;
27
31
  }
28
32
  }
29
- export function personUidFromTokens(tokens) {
33
+ export function cognitoSubFromTokens(tokens) {
30
34
  if (!tokens)
31
35
  return null;
32
36
  for (const token of [tokens.idToken, tokens.accessToken]) {
33
- const uid = claimsOf(token)?.["custom:entityUid"];
34
- if (isPersonUid(uid))
35
- return uid;
37
+ const claims = claimsOf(token);
38
+ if (!claims)
39
+ continue;
40
+ const entityType = claims["custom:entityType"];
41
+ const entityUid = claims["custom:entityUid"];
42
+ if (entityType === "agent" || entityType === "outpost")
43
+ return null;
44
+ if (typeof entityUid === "string" && !entityUid.startsWith("prs_"))
45
+ return null;
46
+ const sub = claims.sub;
47
+ if (isCognitoSub(sub))
48
+ return sub.trim();
36
49
  }
37
50
  return null;
38
51
  }
package/dist/main.d.ts CHANGED
@@ -44,6 +44,9 @@ export type TopLevelErrorDependencies = {
44
44
  openUrl?: (url: string) => Promise<void>;
45
45
  upgradeToTeam?: typeof upgradeToTeam;
46
46
  };
47
+ export declare const MAX_OWN_DIST_RELATIVE_PATH_BYTES = 256;
48
+ /** Return a bounded path only when targetPath is beneath packageRoot/dist. */
49
+ export declare function relativeOwnDistModulePath(packageRootPath: string, targetPath: string): string | undefined;
47
50
  /** Classify a top-level failure without making the CLI process boundary opaque to tests. */
48
51
  export declare function handleTopLevelError(err: unknown, deps?: TopLevelErrorDependencies): Promise<void>;
49
52
  //# sourceMappingURL=main.d.ts.map
package/dist/main.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // Node 20+ API (e.g. util.styleText) or a newer native ABI is evaluated.
7
7
  import "./node-preflight.js";
8
8
  import "./node-network-compat.js";
9
+ import fs from "node:fs";
9
10
  import path from "node:path";
10
11
  import { fileURLToPath } from "node:url";
11
12
  import { Command } from "commander";
@@ -52,10 +53,11 @@ import { settleWithin } from "./utils/settle-with-timeout.js";
52
53
  import { isVersionRequest } from "./version-request.js";
53
54
  import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
54
55
  import { kickFlagRegistryReadiness, waitForFlagRegistryRefresh, } from "./lib/flag-registry.js";
55
- import { isPackageRootResolutionError, packageRootCaptureContext, } from "./utils/package-root-diagnostics.js";
56
+ import { boundedDiagnosticValue, isPackageRootResolutionError, packageRootCaptureContext, } from "./utils/package-root-diagnostics.js";
56
57
  import { registerCommandsWithRecovery } from "./startup-registration.js";
57
58
  import { releasePackageUseLeaseAfterCommandLoad } from "./utils/package-use-lease.js";
58
- import { installTreeTornCaptureContext, installTreeTornStderrLine, isInstallTreeTornError, } from "./utils/install-tree-torn.js";
59
+ import { packageRoot } from "./utils/hq-roots.js";
60
+ import { classifyModuleNotFound, installTreeTornCaptureContext, installTreeTornStderrLine, isInstallTreeTornError, } from "./utils/install-tree-torn.js";
59
61
  import { isVaultAccessDeniedError, vaultAccessDeniedMessage, } from "./utils/vault-access-denied-error.js";
60
62
  import { fallbackOperatorMessage, unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
61
63
  import { runGlobalNetworkLifecycle } from "./utils/cli-network-lifecycle.js";
@@ -410,6 +412,110 @@ function integrationsGatewayCaptureContext(err) {
410
412
  },
411
413
  };
412
414
  }
415
+ export const MAX_OWN_DIST_RELATIVE_PATH_BYTES = 256;
416
+ function resolveThroughExistingAncestors(pathApi, inputPath) {
417
+ let cursor = pathApi.resolve(inputPath);
418
+ const missingSegments = [];
419
+ while (true) {
420
+ try {
421
+ const realPath = fs.realpathSync.native(cursor);
422
+ if (missingSegments.length > 0 && !fs.statSync(realPath).isDirectory()) {
423
+ return undefined;
424
+ }
425
+ return pathApi.join(realPath, ...missingSegments);
426
+ }
427
+ catch (error) {
428
+ const code = error !== null && typeof error === "object" && "code" in error
429
+ ? error.code
430
+ : undefined;
431
+ // A missing final module is expected here. Walk to its nearest existing
432
+ // parent so symlinked package roots and junctions still compare by their
433
+ // actual location. Other filesystem errors leave enrichment unavailable;
434
+ // the original error remains on the generic capture path.
435
+ if (code !== "ENOENT" && code !== "ENOTDIR")
436
+ return undefined;
437
+ const parent = pathApi.dirname(cursor);
438
+ if (parent === cursor)
439
+ return undefined;
440
+ missingSegments.unshift(pathApi.basename(cursor));
441
+ cursor = parent;
442
+ }
443
+ }
444
+ }
445
+ /** Return a bounded path only when targetPath is beneath packageRoot/dist. */
446
+ export function relativeOwnDistModulePath(packageRootPath, targetPath) {
447
+ const hasWindowsAbsolutePath = (value) => /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value);
448
+ const pathApi = hasWindowsAbsolutePath(packageRootPath) || hasWindowsAbsolutePath(targetPath)
449
+ ? path.win32
450
+ : path;
451
+ let resolvedRoot;
452
+ let resolvedTarget;
453
+ if (pathApi === path.win32) {
454
+ // Use Windows lexical semantics for drive paths and UNC paths even when
455
+ // this helper is exercised on a non-Windows host.
456
+ resolvedRoot = pathApi.resolve(packageRootPath);
457
+ resolvedTarget = pathApi.resolve(targetPath);
458
+ }
459
+ else {
460
+ const canonicalRoot = resolveThroughExistingAncestors(pathApi, packageRootPath);
461
+ const canonicalTarget = resolveThroughExistingAncestors(pathApi, targetPath);
462
+ if (canonicalRoot === undefined || canonicalTarget === undefined)
463
+ return undefined;
464
+ resolvedRoot = canonicalRoot;
465
+ resolvedTarget = canonicalTarget;
466
+ }
467
+ const distRoot = pathApi.resolve(resolvedRoot, "dist");
468
+ const relativePath = pathApi.relative(distRoot, resolvedTarget);
469
+ if (relativePath.length === 0 ||
470
+ relativePath === ".." ||
471
+ relativePath.startsWith(`..${pathApi.sep}`) ||
472
+ pathApi.isAbsolute(relativePath)) {
473
+ return undefined;
474
+ }
475
+ return boundedDiagnosticValue(relativePath, MAX_OWN_DIST_RELATIVE_PATH_BYTES);
476
+ }
477
+ /**
478
+ * Add bounded evidence when an ESM resolver failure names a file in hq-cli's
479
+ * own dist tree. This only enriches an event that is already captured; it does
480
+ * not infer that the package tree was torn or change the recovery path.
481
+ */
482
+ function ownDistModuleMissingCaptureContext(err) {
483
+ if (err === null || typeof err !== "object")
484
+ return undefined;
485
+ const record = err;
486
+ if (record.code !== "ERR_MODULE_NOT_FOUND")
487
+ return undefined;
488
+ const classified = classifyModuleNotFound(err);
489
+ if (!classified ||
490
+ classified.code !== "ERR_MODULE_NOT_FOUND" ||
491
+ classified.dialect !== "esm-path" ||
492
+ classified.target.kind !== "path") {
493
+ return undefined;
494
+ }
495
+ let root;
496
+ try {
497
+ root = packageRoot();
498
+ }
499
+ catch {
500
+ // Enrichment is optional. If root discovery itself is unavailable, retain
501
+ // the original generic capture without changing its recovery or output.
502
+ return undefined;
503
+ }
504
+ const relativePath = relativeOwnDistModulePath(root, classified.target.path);
505
+ if (relativePath === undefined)
506
+ return undefined;
507
+ return {
508
+ tags: {
509
+ module_missing_scope: "own_dist",
510
+ cli_version: CLI_VERSION,
511
+ },
512
+ contexts: {
513
+ module_missing_target: {
514
+ relative_path: relativePath,
515
+ },
516
+ },
517
+ };
518
+ }
413
519
  /** Classify a top-level failure without making the CLI process boundary opaque to tests. */
414
520
  export async function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies) {
415
521
  // A typed plan gate, or an hq-cloud vault error carrying a plan-limit
@@ -920,22 +1026,30 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
920
1026
  else {
921
1027
  // A genuinely unclassified fault is still captured exactly once. Two
922
1028
  // shapes attach bounded, hq-derived context so the next occurrence
923
- // carries the evidence this one lacked: a qmd spawn-level failure the fix
924
- // could not attribute (HQ-CLI-1A), and a loader/resolver ENOENT whose path
925
- // did not survive delivery (HQ-CLI-1M esm-load, HQ-CLI-1S resolve-time,
926
- // HQ-CLI-20 CJS load-time — the unattributable incomplete-install shape).
1029
+ // carries evidence otherwise missing: qmd spawn failures (HQ-CLI-1A),
1030
+ // loader/resolver ENOENTs with no usable path (HQ-CLI-1M/1S/20), and an
1031
+ // ESM module miss under hq-cli's own dist tree. The latter remains a
1032
+ // captured fault without guessing whether the artifact or install tore.
927
1033
  // Every other error captures bare, exactly as before.
928
1034
  const spawnContext = qmdSpawnFailureCaptureContext(err);
929
1035
  const installContext = incompleteInstallCaptureContext(err);
930
1036
  const qmdContext = qmdExitCaptureContext(err);
931
1037
  const gatewayContext = integrationsGatewayCaptureContext(err);
932
- if (spawnContext || installContext || qmdContext || gatewayContext) {
1038
+ const moduleContext = ownDistModuleMissingCaptureContext(err);
1039
+ const tags = {
1040
+ ...qmdContext?.tags,
1041
+ ...gatewayContext?.tags,
1042
+ ...moduleContext?.tags,
1043
+ };
1044
+ const contexts = {
1045
+ ...spawnContext?.contexts,
1046
+ ...installContext,
1047
+ ...moduleContext?.contexts,
1048
+ };
1049
+ if (Object.keys(tags).length > 0 || Object.keys(contexts).length > 0) {
933
1050
  deps.sentry.captureException(err, {
934
- ...qmdContext,
935
- ...(gatewayContext ? { tags: gatewayContext.tags } : {}),
936
- ...((spawnContext || installContext)
937
- ? { contexts: { ...spawnContext?.contexts, ...installContext } }
938
- : {}),
1051
+ ...(Object.keys(tags).length > 0 ? { tags } : {}),
1052
+ ...(Object.keys(contexts).length > 0 ? { contexts } : {}),
939
1053
  });
940
1054
  }
941
1055
  else {
package/dist/types.d.ts CHANGED
@@ -9,9 +9,8 @@
9
9
  */
10
10
  import type { ContributionKey } from './utils/contribution-table.js';
11
11
  export type LegacyStrategy = 'link' | 'merge' | 'copy';
12
- export type SyncStrategy = LegacyStrategy | 'package';
13
12
  export type AccessLevel = 'public' | 'team' | `role:${string}`;
14
- export interface PathMapping {
13
+ interface PathMapping {
15
14
  src: string;
16
15
  dest: string;
17
16
  }
@@ -83,7 +82,7 @@ export interface PackAuthor {
83
82
  * tracks installed packs by FILESYSTEM PRESENCE — not `modules.yaml`). `version`
84
83
  * is an optional semver RANGE the installed dependency must satisfy.
85
84
  */
86
- export interface PackDependency {
85
+ interface PackDependency {
87
86
  name: string;
88
87
  version?: string;
89
88
  }
@@ -129,4 +128,5 @@ export interface PackManifest {
129
128
  prompt?: string;
130
129
  };
131
130
  }
131
+ export {};
132
132
  //# sourceMappingURL=types.d.ts.map
@@ -32,19 +32,19 @@
32
32
  * ({@link shouldApplyHeartbeat}).
33
33
  */
34
34
  export declare const CLIENT_HEALTH_CONTRACT_VERSION = 1;
35
- export declare const CLIENT_HEALTH_PLATFORMS: readonly ["macos", "windows", "linux"];
35
+ declare const CLIENT_HEALTH_PLATFORMS: readonly ["macos", "windows", "linux"];
36
36
  export type ClientHealthPlatform = (typeof CLIENT_HEALTH_PLATFORMS)[number];
37
- export declare const CLIENT_HEALTH_ARCHS: readonly ["x64", "arm64"];
37
+ declare const CLIENT_HEALTH_ARCHS: readonly ["x64", "arm64"];
38
38
  export type ClientHealthArch = (typeof CLIENT_HEALTH_ARCHS)[number];
39
39
  /** Which client produced the heartbeat. Desktop is always-on; CLI contributes per invocation. */
40
- export declare const CLIENT_HEALTH_SOURCES: readonly ["desktop", "cli"];
41
- export type ClientHealthSource = (typeof CLIENT_HEALTH_SOURCES)[number];
40
+ declare const CLIENT_HEALTH_SOURCES: readonly ["desktop", "cli"];
41
+ type ClientHealthSource = (typeof CLIENT_HEALTH_SOURCES)[number];
42
42
  /**
43
43
  * Current sync state of the installation. `paused` and `conflict_blocked` are
44
44
  * first-class states (not failures folded into `error`) because support treats
45
45
  * them differently: pause may be intentional and conflicts are user-owned.
46
46
  */
47
- export declare const CLIENT_HEALTH_SYNC_STATES: readonly ["idle", "syncing", "paused", "conflict_blocked", "error", "never_synced"];
47
+ declare const CLIENT_HEALTH_SYNC_STATES: readonly ["idle", "syncing", "paused", "conflict_blocked", "error", "never_synced"];
48
48
  export type ClientHealthSyncState = (typeof CLIENT_HEALTH_SYNC_STATES)[number];
49
49
  /**
50
50
  * Updater state. ABSENCE of the field means the client is too old to report
@@ -62,19 +62,15 @@ export type ClientHealthFailureReason = (typeof CLIENT_HEALTH_FAILURE_REASONS)[n
62
62
  * fails closed.
63
63
  */
64
64
  export declare const CLIENT_HEALTH_REPAIR_KINDS: readonly ["CHECK_NOW", "RETRY_SYNC", "RESUME_SYNC", "REPAIR_CLI", "UPDATE_CORE", "APPLY_DESKTOP_UPDATE", "RESTART_APP"];
65
- export type ClientHealthRepairKind = (typeof CLIENT_HEALTH_REPAIR_KINDS)[number];
65
+ type ClientHealthRepairKind = (typeof CLIENT_HEALTH_REPAIR_KINDS)[number];
66
66
  /** Command/receipt lifecycle states (queued → acknowledged → running → terminal). */
67
- export declare const CLIENT_HEALTH_COMMAND_STATES: readonly ["queued", "acknowledged", "running", "succeeded", "failed", "expired"];
68
- export type ClientHealthCommandState = (typeof CLIENT_HEALTH_COMMAND_STATES)[number];
67
+ declare const CLIENT_HEALTH_COMMAND_STATES: readonly ["queued", "acknowledged", "running", "succeeded", "failed", "expired"];
68
+ type ClientHealthCommandState = (typeof CLIENT_HEALTH_COMMAND_STATES)[number];
69
69
  /** Closed diagnostic probe identifiers (US-007). */
70
- export declare const CLIENT_HEALTH_DIAGNOSTIC_CHECKS: readonly ["auth", "runner", "cli", "core", "updater", "sync", "conflicts", "storage", "permissions"];
71
- export type ClientHealthDiagnosticCheck = (typeof CLIENT_HEALTH_DIAGNOSTIC_CHECKS)[number];
72
- export declare const CLIENT_HEALTH_CHECK_STATUSES: readonly ["pass", "fail", "skip"];
73
- export type ClientHealthCheckStatus = (typeof CLIENT_HEALTH_CHECK_STATUSES)[number];
74
- export declare const CLIENT_HEALTH_MAX_STRING_LENGTH = 64;
75
- export declare const CLIENT_HEALTH_MAX_CONSECUTIVE_FAILURES = 100000;
76
- export declare const CLIENT_HEALTH_MAX_CONFLICT_COUNT = 100000;
77
- export declare const CLIENT_HEALTH_MAX_CHECKS = 16;
70
+ declare const CLIENT_HEALTH_DIAGNOSTIC_CHECKS: readonly ["auth", "runner", "cli", "core", "updater", "sync", "conflicts", "storage", "permissions"];
71
+ type ClientHealthDiagnosticCheck = (typeof CLIENT_HEALTH_DIAGNOSTIC_CHECKS)[number];
72
+ declare const CLIENT_HEALTH_CHECK_STATUSES: readonly ["pass", "fail", "skip"];
73
+ type ClientHealthCheckStatus = (typeof CLIENT_HEALTH_CHECK_STATUSES)[number];
78
74
  /**
79
75
  * Local-files-overview bounds (client-sync-health-control-plane hq-cli
80
76
  * addition, US-016). These cap the closed set of bounded facts support sees
@@ -146,7 +142,7 @@ export interface ClientHealthHeartbeat {
146
142
  /** Absent on older clients / non-sync-outcome events. See {@link ClientHealthLocalFilesOverview}. */
147
143
  localFilesOverview?: ClientHealthLocalFilesOverview;
148
144
  }
149
- export interface ClientHealthCheckResult {
145
+ interface ClientHealthCheckResult {
150
146
  check: ClientHealthDiagnosticCheck;
151
147
  status: ClientHealthCheckStatus;
152
148
  /** Present only on `fail` — a closed reason code, never prose. */
@@ -187,4 +183,5 @@ export declare function parseClientHealthCommandReceipt(input: unknown): ClientH
187
183
  * answers idempotently and the current snapshot remains unchanged).
188
184
  */
189
185
  export declare function shouldApplyHeartbeat(storedSequence: number | undefined, incomingSequence: number): boolean;
186
+ export {};
190
187
  //# sourceMappingURL=client-health-contract.d.ts.map
@@ -34,16 +34,16 @@
34
34
  // ─── Contract version ────────────────────────────────────────────────────────
35
35
  export const CLIENT_HEALTH_CONTRACT_VERSION = 1;
36
36
  // ─── Closed enums ────────────────────────────────────────────────────────────
37
- export const CLIENT_HEALTH_PLATFORMS = ["macos", "windows", "linux"];
38
- export const CLIENT_HEALTH_ARCHS = ["x64", "arm64"];
37
+ const CLIENT_HEALTH_PLATFORMS = ["macos", "windows", "linux"];
38
+ const CLIENT_HEALTH_ARCHS = ["x64", "arm64"];
39
39
  /** Which client produced the heartbeat. Desktop is always-on; CLI contributes per invocation. */
40
- export const CLIENT_HEALTH_SOURCES = ["desktop", "cli"];
40
+ const CLIENT_HEALTH_SOURCES = ["desktop", "cli"];
41
41
  /**
42
42
  * Current sync state of the installation. `paused` and `conflict_blocked` are
43
43
  * first-class states (not failures folded into `error`) because support treats
44
44
  * them differently: pause may be intentional and conflicts are user-owned.
45
45
  */
46
- export const CLIENT_HEALTH_SYNC_STATES = [
46
+ const CLIENT_HEALTH_SYNC_STATES = [
47
47
  "idle",
48
48
  "syncing",
49
49
  "paused",
@@ -94,7 +94,7 @@ export const CLIENT_HEALTH_REPAIR_KINDS = [
94
94
  "RESTART_APP",
95
95
  ];
96
96
  /** Command/receipt lifecycle states (queued → acknowledged → running → terminal). */
97
- export const CLIENT_HEALTH_COMMAND_STATES = [
97
+ const CLIENT_HEALTH_COMMAND_STATES = [
98
98
  "queued",
99
99
  "acknowledged",
100
100
  "running",
@@ -103,7 +103,7 @@ export const CLIENT_HEALTH_COMMAND_STATES = [
103
103
  "expired",
104
104
  ];
105
105
  /** Closed diagnostic probe identifiers (US-007). */
106
- export const CLIENT_HEALTH_DIAGNOSTIC_CHECKS = [
106
+ const CLIENT_HEALTH_DIAGNOSTIC_CHECKS = [
107
107
  "auth",
108
108
  "runner",
109
109
  "cli",
@@ -114,12 +114,12 @@ export const CLIENT_HEALTH_DIAGNOSTIC_CHECKS = [
114
114
  "storage",
115
115
  "permissions",
116
116
  ];
117
- export const CLIENT_HEALTH_CHECK_STATUSES = ["pass", "fail", "skip"];
117
+ const CLIENT_HEALTH_CHECK_STATUSES = ["pass", "fail", "skip"];
118
118
  // ─── Bounds ──────────────────────────────────────────────────────────────────
119
- export const CLIENT_HEALTH_MAX_STRING_LENGTH = 64;
120
- export const CLIENT_HEALTH_MAX_CONSECUTIVE_FAILURES = 100_000;
121
- export const CLIENT_HEALTH_MAX_CONFLICT_COUNT = 100_000;
122
- export const CLIENT_HEALTH_MAX_CHECKS = 16;
119
+ const CLIENT_HEALTH_MAX_STRING_LENGTH = 64;
120
+ const CLIENT_HEALTH_MAX_CONSECUTIVE_FAILURES = 100_000;
121
+ const CLIENT_HEALTH_MAX_CONFLICT_COUNT = 100_000;
122
+ const CLIENT_HEALTH_MAX_CHECKS = 16;
123
123
  /**
124
124
  * Local-files-overview bounds (client-sync-health-control-plane hq-cli
125
125
  * addition, US-016). These cap the closed set of bounded facts support sees
@@ -145,6 +145,8 @@ export interface SelfUpdateDeps {
145
145
  /** Same-filesystem install rename, injectable for rollback tests. */
146
146
  renamePath?: typeof fs.renameSync;
147
147
  runner?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
148
+ /** Delay between bounded npm retries when the just-published target is not visible yet. */
149
+ sleep?: (ms: number) => Promise<void>;
148
150
  reexec?: (argv: string[], env: NodeJS.ProcessEnv) => number | null;
149
151
  /** Async process-spawn seam for lease handoff tests. */
150
152
  spawnReexec?: typeof spawn;
@@ -693,6 +693,18 @@ function reexecErrorClass(error) {
693
693
  }
694
694
  return "unknown";
695
695
  }
696
+ const NPM_NOTARGET_RETRY_DELAYS_MS = [30_000, 60_000, 90_000];
697
+ const NPM_NOTARGET_MAX_ATTEMPTS = NPM_NOTARGET_RETRY_DELAYS_MS.length + 1;
698
+ function isNpmNotargetFailure(result) {
699
+ const code = (result.diagnosticCode ?? result.code)?.toUpperCase();
700
+ if (code === "ETARGET" || code === "NOTARGET")
701
+ return true;
702
+ return result.failureKind === "package_manager_exit" &&
703
+ /\bETARGET\b|\bnpm\s+(?:ERR!|error)\s+notarget\b/i.test(result.detail ?? "");
704
+ }
705
+ function sleepForRetry(ms) {
706
+ return new Promise((resolve) => setTimeout(resolve, ms));
707
+ }
696
708
  async function updateAndReexec(argv, flavor, known, deps) {
697
709
  const env = deps.env ?? process.env;
698
710
  if (env[REEXEC_GUARD_ENV] === "1" || env.HQ_NO_UPDATE_CHECK === "1") {
@@ -795,14 +807,32 @@ async function updateAndReexec(argv, flavor, known, deps) {
795
807
  // Forward env only when PNPM_HOME had to be injected, so the common path
796
808
  // spawns with the inherited environment and keeps the two-argument runner
797
809
  // call it has always made.
798
- if (staged) {
799
- result = stagedNpmInstall(install, latestValid, { run: runner, env, rename: renamePath });
800
- }
801
- else if (updateEnv === undefined) {
802
- result = runner(plan.cmd, plan.args);
810
+ const runInstall = () => {
811
+ if (staged) {
812
+ return stagedNpmInstall(install, latestValid, { run: runner, env, rename: renamePath });
813
+ }
814
+ return updateEnv === undefined
815
+ ? runner(plan.cmd, plan.args)
816
+ : runner(plan.cmd, plan.args, updateEnv);
817
+ };
818
+ let attempt = 1;
819
+ result = runInstall();
820
+ while (install.manager === "npm" &&
821
+ !result.ok &&
822
+ isNpmNotargetFailure(result) &&
823
+ attempt < NPM_NOTARGET_MAX_ATTEMPTS) {
824
+ const delay = NPM_NOTARGET_RETRY_DELAYS_MS[attempt - 1];
825
+ const nextAttempt = attempt + 1;
826
+ console.error(chalk.dim(` npm has not published hq-cli ${latestValid} yet; retrying install (attempt ${nextAttempt}/${NPM_NOTARGET_MAX_ATTEMPTS}) after ${delay} ms.`));
827
+ await (deps.sleep ?? sleepForRetry)(delay);
828
+ attempt = nextAttempt;
829
+ result = runInstall();
803
830
  }
804
- else {
805
- result = runner(plan.cmd, plan.args, updateEnv);
831
+ if (!result.ok && attempt > 1) {
832
+ result = {
833
+ ...result,
834
+ detail: `${result.detail ?? "npm install failed"} (attempts: ${attempt})`,
835
+ };
806
836
  }
807
837
  if (result.ok && !staged && install.manager === "npm" && install.prefix && install.packageRoot) {
808
838
  const bins = verifyOrRepairNpmBins(install.prefix, install.packageRoot);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.5",
3
+ "version": "5.345.7",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {