@indigoai-us/hq-cli 5.343.1 → 5.344.1

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,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.344.1] — 2026-10-05
6
+ - Release the MCP server's package lease after its resident modules preload and record a bounded command purpose on CLI package leases.
7
+
8
+ ## [5.344.0] — 2026-10-04
9
+ - Include a bounded reason in sync-failure client-health reports.
10
+ - Add an explicit opt-in to count usage from HQ sessions with no company toward the default company: `hq mesh context default set` asks in a terminal (default No), takes `--count-usage` / `--no-count-usage`, and writes `usageAttribution` (company and signed-in person) to the work-context config. Changing or clearing the default, logging out, or signing in as another account withdraws it; `get` shows it; an automatic default never opts in.
11
+
5
12
  ## [5.343.1] — 2026-10-04
6
13
 
7
14
  - Include the bounded hq-cli version in `cli_feature_used` telemetry so feature-use rows can be grouped by release.
@@ -9678,7 +9678,7 @@ export declare const COMMAND_CATALOG: readonly [{
9678
9678
  readonly options: readonly [];
9679
9679
  readonly subcommands: readonly [{
9680
9680
  readonly name: "get";
9681
- readonly description: "Show the device default company (never activeCompany)";
9681
+ readonly description: "Show the device default company (never activeCompany) and whether usage is counted toward it";
9682
9682
  readonly aliases: readonly [];
9683
9683
  readonly hidden: false;
9684
9684
  readonly helpOption: true;
@@ -9700,7 +9700,10 @@ export declare const COMMAND_CATALOG: readonly [{
9700
9700
  readonly helpOption: true;
9701
9701
  readonly helpCommand: false;
9702
9702
  readonly usage: "[options] <slug>";
9703
- readonly helpText: readonly [];
9703
+ readonly helpText: readonly [{
9704
+ readonly position: "after";
9705
+ readonly text: "\nWith neither --count-usage nor --no-count-usage, an interactive terminal asks (default No);\notherwise an existing opt-in is kept only when it is for the same company.\nChanging to a different company always stops counting usage toward the old one.";
9706
+ }];
9704
9707
  readonly arguments: readonly [{
9705
9708
  readonly name: "slug";
9706
9709
  readonly required: true;
@@ -9715,6 +9718,14 @@ export declare const COMMAND_CATALOG: readonly [{
9715
9718
  readonly flags: "--repair-held-with-default";
9716
9719
  readonly description: "Persist opt-in for lower-confidence historical held-session repair";
9717
9720
  readonly hidden: false;
9721
+ }, {
9722
+ readonly flags: "--count-usage";
9723
+ readonly description: "Count usage from HQ sessions with no company toward this company (its owner and admins can see it)";
9724
+ readonly hidden: false;
9725
+ }, {
9726
+ readonly flags: "--no-count-usage";
9727
+ readonly description: "Do not count usage from HQ sessions with no company toward this company";
9728
+ readonly hidden: false;
9718
9729
  }, {
9719
9730
  readonly flags: "--company <slug|uid>";
9720
9731
  readonly description: "Deprecated no-op; the selected company is verified directly";
@@ -9727,7 +9738,7 @@ export declare const COMMAND_CATALOG: readonly [{
9727
9738
  readonly subcommands: readonly [];
9728
9739
  }, {
9729
9740
  readonly name: "clear";
9730
- readonly description: "Clear the device default company";
9741
+ readonly description: "Clear the device default company and any opt-in to count usage toward it";
9731
9742
  readonly aliases: readonly [];
9732
9743
  readonly hidden: false;
9733
9744
  readonly helpOption: true;
@@ -11737,7 +11737,7 @@ export const COMMAND_CATALOG = [
11737
11737
  "subcommands": [
11738
11738
  {
11739
11739
  "name": "get",
11740
- "description": "Show the device default company (never activeCompany)",
11740
+ "description": "Show the device default company (never activeCompany) and whether usage is counted toward it",
11741
11741
  "aliases": [],
11742
11742
  "hidden": false,
11743
11743
  "helpOption": true,
@@ -11762,7 +11762,12 @@ export const COMMAND_CATALOG = [
11762
11762
  "helpOption": true,
11763
11763
  "helpCommand": false,
11764
11764
  "usage": "[options] <slug>",
11765
- "helpText": [],
11765
+ "helpText": [
11766
+ {
11767
+ "position": "after",
11768
+ "text": "\nWith neither --count-usage nor --no-count-usage, an interactive terminal asks (default No);\notherwise an existing opt-in is kept only when it is for the same company.\nChanging to a different company always stops counting usage toward the old one."
11769
+ }
11770
+ ],
11766
11771
  "arguments": [
11767
11772
  {
11768
11773
  "name": "slug",
@@ -11782,6 +11787,16 @@ export const COMMAND_CATALOG = [
11782
11787
  "description": "Persist opt-in for lower-confidence historical held-session repair",
11783
11788
  "hidden": false
11784
11789
  },
11790
+ {
11791
+ "flags": "--count-usage",
11792
+ "description": "Count usage from HQ sessions with no company toward this company (its owner and admins can see it)",
11793
+ "hidden": false
11794
+ },
11795
+ {
11796
+ "flags": "--no-count-usage",
11797
+ "description": "Do not count usage from HQ sessions with no company toward this company",
11798
+ "hidden": false
11799
+ },
11785
11800
  {
11786
11801
  "flags": "--company <slug|uid>",
11787
11802
  "description": "Deprecated no-op; the selected company is verified directly",
@@ -11797,7 +11812,7 @@ export const COMMAND_CATALOG = [
11797
11812
  },
11798
11813
  {
11799
11814
  "name": "clear",
11800
- "description": "Clear the device default company",
11815
+ "description": "Clear the device default company and any opt-in to count usage toward it",
11801
11816
  "aliases": [],
11802
11817
  "hidden": false,
11803
11818
  "helpOption": true,
@@ -20,7 +20,7 @@ import { share, sync, getStateDir, listJournals, VaultClient, computePersonalVau
20
20
  import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, loadCachedTokens, } from "../utils/cognito-session.js";
21
21
  import { resolveUploadAuthor } from "../utils/upload-author.js";
22
22
  import { companyFolderExceedsThreshold, emitNarrowHint, isStrictRefusal, resolveBannerLevel, resolveNarrowHintPresentationLevel, resolveNarrowHintMinBytes, } from "../lib/narrow-hint-banner.js";
23
- import { beginSyncHealthReport, } from "../utils/client-health.js";
23
+ import { beginSyncHealthReport, classifySyncFailure, } from "../utils/client-health.js";
24
24
  import { companySlugFromSyncPaths, warnAboutInvalidOntologyEntities, } from "../lib/sync-invalid-entity-warning.js";
25
25
  import { isWorkBearingSyncResult, recordFirstWorkBearingSync, } from "../lib/first-sync-next-step.js";
26
26
  import { maybeShowCliInviteNudge } from "../lib/cli-invite-nudge.js";
@@ -125,7 +125,7 @@ export async function withSyncHealthReport(run) {
125
125
  await health.succeeded();
126
126
  }
127
127
  else {
128
- await health.failed();
128
+ await health.failed(classifySyncFailure(err));
129
129
  }
130
130
  if (err instanceof SyncExitError)
131
131
  process.exit(err.code);
@@ -8,7 +8,8 @@ import { browserLogin, loadCachedTokens, isExpiring } from '@indigoai-us/hq-clou
8
8
  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
- import { setDefaultCompany } from "../lib/work-context/config.js";
11
+ import { retainUsageAttributionForPerson, setDefaultCompany } from "../lib/work-context/config.js";
12
+ import { personUidFromTokens } from "../lib/work-context/person-identity.js";
12
13
  import { ensureDefaultCompany } from "../lib/work-context/default-company.js";
13
14
  import { workContextRoot } from "../lib/work-context/paths.js";
14
15
  function peekIdToken(idToken) {
@@ -62,6 +63,16 @@ export function registerLoginCommand(program) {
62
63
  process.exitCode = 1;
63
64
  return;
64
65
  }
66
+ try {
67
+ // The usage-attribution opt-in belongs to the person who gave it. A
68
+ // different (or unknown) account signing in on this OS user drops it.
69
+ retainUsageAttributionForPerson(personUidFromTokens(loadCachedTokens()), {
70
+ root: workContextRoot(),
71
+ });
72
+ }
73
+ catch {
74
+ /* fail closed below: readers also require the signed-in person to match */
75
+ }
65
76
  try {
66
77
  const root = workContextRoot();
67
78
  const activation = await ensureDefaultCompany({
@@ -3,6 +3,8 @@
3
3
  */
4
4
  import chalk from 'chalk';
5
5
  import { clearCachedTokens, loadCachedTokens } from '@indigoai-us/hq-cloud';
6
+ import { retainUsageAttributionForPerson } from '../lib/work-context/config.js';
7
+ import { workContextRoot } from '../lib/work-context/paths.js';
6
8
  export function registerLogoutCommand(program) {
7
9
  program
8
10
  .command('logout')
@@ -11,6 +13,14 @@ export function registerLogoutCommand(program) {
11
13
  try {
12
14
  const { clearMcpOAuthCaches } = await import('../lib/daemon/mcp-oauth.js');
13
15
  clearMcpOAuthCaches();
16
+ // Signing out withdraws the opt-in to count no-company usage toward the
17
+ // default company; the next account must opt in for itself.
18
+ try {
19
+ retainUsageAttributionForPerson(null, { root: workContextRoot() });
20
+ }
21
+ catch (error) {
22
+ console.error(chalk.yellow('Could not withdraw the usage opt-in:'), error instanceof Error ? error.message : 'Unknown error');
23
+ }
14
24
  const existing = loadCachedTokens();
15
25
  if (!existing) {
16
26
  console.log(chalk.yellow('No cached HQ session'));
@@ -73,11 +73,12 @@ export async function runMcpServe(opts = {}) {
73
73
  process.emitWarning("hq-cli resident package preload completion failed (class=Error, module=unknown)");
74
74
  });
75
75
  };
76
+ // Release before the stdio read can remain open indefinitely. The request
77
+ // handlers and their declared lazy modules are loaded before this point.
78
+ startResidentPreload();
76
79
  const send = (msg) => {
77
80
  const response = `${JSON.stringify({ jsonrpc: "2.0", ...msg })}\n`;
78
- if (residentPreloadStarted)
79
- return output.write(response);
80
- return output.write(response, startResidentPreload);
81
+ return output.write(response);
81
82
  };
82
83
  const reply = (id, result) => send({ id, result });
83
84
  const replyError = (id, code, message) => send({ id, error: { code, message } });
@@ -10,6 +10,30 @@ import { type MeshCompany, type MeshThread } from "../lib/mesh/api.js";
10
10
  import { type DaemonInstallPlatform, type InstallPaths } from "../lib/mesh/live/daemon/index.js";
11
11
  import { type RunCommand } from "../lib/service-manager/launchd.js";
12
12
  export declare function formatCheckLines(threads: MeshThread[], company: MeshCompany, projectId?: string): string[];
13
+ /** Test seam for the usage-attribution question asked by `default set`. */
14
+ export interface DefaultCompanyPromptDeps {
15
+ /** True when stdin and stdout are a terminal (a person can answer). */
16
+ isInteractive?: () => boolean;
17
+ /** Ask a yes/no question; resolves true only for an explicit yes. */
18
+ confirm?: (question: string) => Promise<boolean>;
19
+ }
20
+ export declare function usageOptInExplainer(company: string): string;
21
+ export declare function usageOptInQuestion(company: string): string;
22
+ export declare function usageCountedLine(company: string): string;
23
+ export declare function usageNotCountedLine(company: string): string;
24
+ /** Only "y" or "yes" (any case, surrounding space ignored) is a yes. EOF (null) is no. */
25
+ export declare function parseYesNoAnswer(answer: string | null): boolean;
26
+ /** A person can answer only when both stdin and stdout are a terminal. */
27
+ export declare function isInteractiveTerminal(stdin?: {
28
+ isTTY?: boolean;
29
+ }, stdout?: {
30
+ isTTY?: boolean;
31
+ }): boolean;
32
+ /** Ask one yes/no question. Resolves false on EOF or a closed input. */
33
+ export declare function askYesNo(question: string, io?: {
34
+ input?: NodeJS.ReadableStream;
35
+ output?: NodeJS.WritableStream;
36
+ }): Promise<boolean>;
13
37
  /** Injectable confirmation seam for `hq mesh context correct` (stubbed in tests). */
14
38
  export type MeshConfirmFn = (message: string) => Promise<boolean>;
15
39
  /** Exported for confirmation-gate unit tests (US-017B). */
@@ -25,7 +49,9 @@ export declare function runContextCorrect(opts: {
25
49
  }, deps?: {
26
50
  confirm?: MeshConfirmFn;
27
51
  }): Promise<void>;
28
- export declare function registerMeshCommand(program: Command): void;
52
+ export declare function registerMeshCommand(program: Command, deps?: {
53
+ defaultCompanyPrompt?: DefaultCompanyPromptDeps;
54
+ }): void;
29
55
  export declare function runDaemonStatus(opts: {
30
56
  json?: boolean;
31
57
  }, deps?: {
@@ -18,9 +18,10 @@ import { closeSupersededProjectThread, ensureMissingBoardStories, ensureProjectB
18
18
  import { mapSourceStoryStatus } from "../lib/mesh/story-status.js";
19
19
  import { buildPreflightFixtures } from "../lib/work-context/preflight-contract.js";
20
20
  import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
21
- import { clearDefaultCompany, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, } from "../lib/work-context/config.js";
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 { DefaultCompanyUnavailableError, } from "../lib/work-context/errors.js";
23
+ import { personUidFromTokens } from "../lib/work-context/person-identity.js";
24
+ import { DefaultCompanyUnavailableError, WorkContextError, } from "../lib/work-context/errors.js";
24
25
  import { isValidSessionId } from "../lib/mesh/live/session-identity.js";
25
26
  import { CLI_KIND_TO_SCHEMA, resolveEnqueueSessionId, } from "../lib/mesh/live/index.js";
26
27
  import { flushSessionEvents } from "../lib/mesh/live/flush.js";
@@ -1170,6 +1171,58 @@ async function runContextBackfillHeld(opts) {
1170
1171
  "the mesh daemon's held retry will re-attribute and post those events.");
1171
1172
  }
1172
1173
  }
1174
+ export function usageOptInExplainer(company) {
1175
+ return `Opting in counts usage from HQ sessions started with no company toward ${company}. Sessions bound to a company count toward that company either way.`;
1176
+ }
1177
+ export function usageOptInQuestion(company) {
1178
+ return `Count usage from HQ sessions with no company toward ${company}? Your company's owner and admins will see that usage, including tokens, models, and the repo and branch you worked in. [y/N] `;
1179
+ }
1180
+ export function usageCountedLine(company) {
1181
+ return `Usage from HQ sessions with no company is counted toward ${company}.`;
1182
+ }
1183
+ export function usageNotCountedLine(company) {
1184
+ return `Usage from HQ sessions with no company is not counted toward ${company}. To opt in, run: hq mesh context default set ${company} --count-usage`;
1185
+ }
1186
+ /** Only "y" or "yes" (any case, surrounding space ignored) is a yes. EOF (null) is no. */
1187
+ export function parseYesNoAnswer(answer) {
1188
+ return answer !== null && /^y(es)?$/i.test(answer.trim());
1189
+ }
1190
+ /** A person can answer only when both stdin and stdout are a terminal. */
1191
+ export function isInteractiveTerminal(stdin = process.stdin, stdout = process.stdout) {
1192
+ return stdin.isTTY === true && stdout.isTTY === true;
1193
+ }
1194
+ /** Ask one yes/no question. Resolves false on EOF or a closed input. */
1195
+ export async function askYesNo(question, io = {}) {
1196
+ const rl = readline.createInterface({
1197
+ input: io.input ?? process.stdin,
1198
+ output: io.output ?? process.stderr,
1199
+ terminal: false,
1200
+ });
1201
+ const closed = new Promise((resolve) => rl.once("close", () => resolve(null)));
1202
+ try {
1203
+ const answer = await Promise.race([
1204
+ rl.question(question).catch(() => null),
1205
+ closed,
1206
+ ]);
1207
+ return parseYesNoAnswer(answer);
1208
+ }
1209
+ finally {
1210
+ rl.close();
1211
+ }
1212
+ }
1213
+ /** The signed-in person's prs_ uid from the cached Cognito session, or null. */
1214
+ function currentPersonUid() {
1215
+ try {
1216
+ return personUidFromTokens(loadCachedTokens());
1217
+ }
1218
+ catch {
1219
+ return null;
1220
+ }
1221
+ }
1222
+ function usageAttributionJson(cfg, personUid) {
1223
+ const effective = effectiveUsageAttribution(cfg, personUid);
1224
+ return { countUsage: effective !== null, usageAttribution: effective };
1225
+ }
1173
1226
  async function runContextDefaultGet(opts) {
1174
1227
  const root = workContextHomeRoot();
1175
1228
  const cfg = readDeviceConfig({ root });
@@ -1189,6 +1242,7 @@ async function runContextDefaultGet(opts) {
1189
1242
  needsChoice: !current && !disabled && memberships.length > 1,
1190
1243
  memberships,
1191
1244
  repairHeldWithDefault: cfg.repairHeldWithDefault === true,
1245
+ ...usageAttributionJson(cfg, currentPersonUid()),
1192
1246
  }, null, 2));
1193
1247
  return;
1194
1248
  }
@@ -1201,10 +1255,20 @@ async function runContextDefaultGet(opts) {
1201
1255
  return;
1202
1256
  }
1203
1257
  console.log(`Default company: ${current.slug}${current.uid ? ` (${current.uid})` : ""} enabled=${current.enabled} source=${source}`);
1258
+ console.log(effectiveUsageAttribution(cfg, currentPersonUid())
1259
+ ? usageCountedLine(current.slug)
1260
+ : usageNotCountedLine(current.slug));
1204
1261
  }
1205
- async function runContextDefaultSet(slug, opts) {
1262
+ async function runContextDefaultSet(slug, opts, promptDeps = {}) {
1206
1263
  const root = workContextHomeRoot();
1207
1264
  void opts.company; // retained for CLI compatibility
1265
+ const usageChoice = opts.countUsage === true
1266
+ ? "opt-in"
1267
+ : opts.countUsage === false
1268
+ ? "opt-out"
1269
+ : "keep-if-same";
1270
+ const personUid = currentPersonUid();
1271
+ const priorOptIn = effectiveUsageAttribution(readDeviceConfig({ root }), personUid);
1208
1272
  let token;
1209
1273
  try {
1210
1274
  token = await requireToken();
@@ -1244,6 +1308,8 @@ async function runContextDefaultSet(slug, opts) {
1244
1308
  const cfg = await setDefaultCompany(slug, {
1245
1309
  root,
1246
1310
  migrationCapability,
1311
+ usageAttribution: usageChoice,
1312
+ personUid,
1247
1313
  validateMembership: async (candidate) => {
1248
1314
  if (!token) {
1249
1315
  throw new DefaultCompanyUnavailableError(`Cannot verify membership for company "${candidate}" (no Cognito session; offline or not logged in)`);
@@ -1261,19 +1327,55 @@ async function runContextDefaultSet(slug, opts) {
1261
1327
  return { uid: membership.companyUid };
1262
1328
  },
1263
1329
  });
1264
- const configured = opts.repairHeldWithDefault
1330
+ let configured = opts.repairHeldWithDefault
1265
1331
  ? setRepairHeldWithDefault(true, { root })
1266
1332
  : cfg;
1333
+ const stored = configured.defaultCompany;
1334
+ const company = stored?.slug ?? slug;
1267
1335
  if (opts.json) {
1268
- console.log(JSON.stringify({ ok: true, config: configured, migrationWarning }, null, 2));
1336
+ // Machine output never prompts; the opt-in changes only with a flag.
1337
+ console.log(JSON.stringify({
1338
+ ok: true,
1339
+ config: configured,
1340
+ migrationWarning,
1341
+ ...usageAttributionJson(configured, personUid),
1342
+ }, null, 2));
1269
1343
  return;
1270
1344
  }
1271
- const stored = configured.defaultCompany;
1272
- console.log(`Default company set to ${stored?.slug ?? slug}${stored?.uid ? ` (${stored.uid})` : ""}`);
1345
+ console.log(`Default company set to ${company}${stored?.uid ? ` (${stored.uid})` : ""}`);
1346
+ if (priorOptIn && priorOptIn.companyUid !== stored?.uid) {
1347
+ console.log("Usage is no longer counted toward the previous default company.");
1348
+ }
1349
+ const interactive = (promptDeps.isInteractive ??
1350
+ (() => isInteractiveTerminal()))();
1351
+ if (usageChoice === "keep-if-same" &&
1352
+ interactive &&
1353
+ personUid &&
1354
+ !effectiveUsageAttribution(configured, personUid) &&
1355
+ stored?.uid?.startsWith("cmp_")) {
1356
+ console.error(usageOptInExplainer(company));
1357
+ let yes = false;
1358
+ try {
1359
+ yes = await (promptDeps.confirm ?? askYesNo)(usageOptInQuestion(company));
1360
+ }
1361
+ catch {
1362
+ yes = false;
1363
+ }
1364
+ if (yes)
1365
+ configured = setUsageAttribution({ companyUid: stored.uid, personUid }, { root });
1366
+ }
1367
+ console.log(effectiveUsageAttribution(configured, personUid)
1368
+ ? usageCountedLine(company)
1369
+ : usageNotCountedLine(company));
1273
1370
  if (migrationWarning)
1274
1371
  console.error(chalk.yellow(migrationWarning));
1275
1372
  }
1276
1373
  catch (err) {
1374
+ if (err instanceof WorkContextError && err.code === "UsageAttributionUnavailable") {
1375
+ console.error(chalk.red(`${err.code}: ${err.message}`));
1376
+ process.exitCode = 1;
1377
+ return;
1378
+ }
1277
1379
  if (err instanceof DefaultCompanyUnavailableError) {
1278
1380
  console.error(chalk.red(`${err.code}: ${err.message}`));
1279
1381
  process.exitCode = 1;
@@ -1396,12 +1498,16 @@ export async function runContextCorrect(opts, deps = {}) {
1396
1498
  }
1397
1499
  async function runContextDefaultClear(opts) {
1398
1500
  const root = workContextHomeRoot();
1501
+ const hadOptIn = readDeviceConfig({ root }).usageAttribution !== undefined;
1399
1502
  const cfg = clearDefaultCompany({ root });
1400
1503
  if (opts.json) {
1401
- console.log(JSON.stringify({ ok: true, config: cfg }, null, 2));
1504
+ console.log(JSON.stringify({ ok: true, config: cfg, ...usageAttributionJson(cfg, currentPersonUid()) }, null, 2));
1402
1505
  return;
1403
1506
  }
1404
1507
  console.log("Default company cleared.");
1508
+ if (hadOptIn) {
1509
+ console.log("Usage from HQ sessions with no company is no longer counted toward any company.");
1510
+ }
1405
1511
  }
1406
1512
  async function runContextRequeue(opts) {
1407
1513
  const root = workContextHomeRoot();
@@ -1854,7 +1960,7 @@ function addSharedFlags(cmd) {
1854
1960
  .option("--thread-id <id>", "Explicit work thread id")
1855
1961
  .option("--json", "Print machine-readable JSON");
1856
1962
  }
1857
- export function registerMeshCommand(program) {
1963
+ export function registerMeshCommand(program, deps = {}) {
1858
1964
  const mesh = program
1859
1965
  .command("mesh")
1860
1966
  .description("Work mesh — register project work, report progress, and warm ~/.hq/work-mesh/cache");
@@ -2030,7 +2136,7 @@ export function registerMeshCommand(program) {
2030
2136
  .description("Manage the device-local default company preference");
2031
2137
  def
2032
2138
  .command("get")
2033
- .description("Show the device default company (never activeCompany)")
2139
+ .description("Show the device default company (never activeCompany) and whether usage is counted toward it")
2034
2140
  .option("--json", "Print machine-readable JSON")
2035
2141
  .action((opts) => wrap(() => runContextDefaultGet(opts))());
2036
2142
  def
@@ -2039,12 +2145,17 @@ export function registerMeshCommand(program) {
2039
2145
  .argument("<slug>", "Company slug")
2040
2146
  .option("--allow-without-migration", "Deprecated compatibility no-op; selected-company membership is always verified")
2041
2147
  .option("--repair-held-with-default", "Persist opt-in for lower-confidence historical held-session repair")
2148
+ .option("--count-usage", "Count usage from HQ sessions with no company toward this company (its owner and admins can see it)")
2149
+ .option("--no-count-usage", "Do not count usage from HQ sessions with no company toward this company")
2042
2150
  .option("--company <slug|uid>", "Deprecated no-op; the selected company is verified directly")
2043
2151
  .option("--json", "Print machine-readable JSON")
2044
- .action((slug, opts) => wrap(() => runContextDefaultSet(slug, opts))());
2152
+ .addHelpText("after", "\nWith neither --count-usage nor --no-count-usage, an interactive terminal asks (default No);\n" +
2153
+ "otherwise an existing opt-in is kept only when it is for the same company.\n" +
2154
+ "Changing to a different company always stops counting usage toward the old one.")
2155
+ .action((slug, opts) => wrap(() => runContextDefaultSet(slug, opts, deps.defaultCompanyPrompt))());
2045
2156
  def
2046
2157
  .command("clear")
2047
- .description("Clear the device default company")
2158
+ .description("Clear the device default company and any opt-in to count usage toward it")
2048
2159
  .option("--json", "Print machine-readable JSON")
2049
2160
  .action((opts) => wrap(() => runContextDefaultClear(opts))());
2050
2161
  context
@@ -44,10 +44,41 @@ export interface RepoCompanyMapping {
44
44
  updatedAt: string;
45
45
  }
46
46
  type RepoCompanyMap = Record<string, RepoCompanyMapping>;
47
+ /**
48
+ * Explicit opt-in to count usage from HQ sessions with no company toward the
49
+ * device default company. hq-cloud's usage sync reads this field and credits
50
+ * the default company only when mode is exactly "default-company", companyUid
51
+ * starts with "cmp_" and equals the default company's uid, and optedInAt
52
+ * parses as a date. Company owners and admins can see company-attributed
53
+ * usage, so this is never written without the person's explicit consent.
54
+ */
55
+ export interface UsageAttributionOptIn {
56
+ mode: "default-company";
57
+ companyUid: string;
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.
62
+ */
63
+ personUid: string;
64
+ optedInAt: string;
65
+ }
66
+ /** A person principal uid, as carried in the `custom:entityUid` token claim. */
67
+ export declare function isPersonUid(value: unknown): value is string;
68
+ /** How `setDefaultCompany` treats the usage-attribution opt-in. */
69
+ export type UsageAttributionChoice =
70
+ /** Write an opt-in for the newly set company (requires its uid). */
71
+ "opt-in"
72
+ /** Remove any opt-in. */
73
+ | "opt-out"
74
+ /** Keep an existing opt-in only when it is for the same company uid. */
75
+ | "keep-if-same";
47
76
  export interface WorkContextDeviceConfig {
48
77
  schemaVersion: typeof DEVICE_CONFIG_SCHEMA_VERSION;
49
78
  /** Explicit device default. Never copied from activeCompany. */
50
79
  defaultCompany?: DeviceDefaultCompany | null;
80
+ /** Opt-in to count no-company HQ session usage toward the default company. */
81
+ usageAttribution?: UsageAttributionOptIn;
51
82
  /** Marks a null default written by the automatic-activation implementation. */
52
83
  defaultCompanyAutomaticEligible?: true;
53
84
  /** Last-known migration capability across memberships (doctor reports this). */
@@ -75,12 +106,31 @@ export interface DeviceConfigDeps {
75
106
  */
76
107
  validateMembership?: (slug: string) => Promise<MembershipValidationResult> | MembershipValidationResult;
77
108
  now?: () => Date;
109
+ /**
110
+ * Usage-attribution opt-in handling for setDefaultCompany. Defaults to
111
+ * "keep-if-same": an existing opt-in survives only when it is for the same
112
+ * company uid, so changing the default never carries consent across companies.
113
+ */
114
+ usageAttribution?: UsageAttributionChoice;
115
+ /**
116
+ * The signed-in person (prs_...). Required to write an opt-in; an existing
117
+ * opt-in is kept only when it was given by this person.
118
+ */
119
+ personUid?: string | null;
78
120
  }
79
121
  /** Acquire an interprocess config lock; exported for direct lock regression tests. */
80
122
  export declare function acquireDeviceConfigLock(root: string, options?: {
81
123
  timeoutMs?: number;
82
124
  staleMs?: number;
83
125
  }): () => void;
126
+ /** Parse a raw usageAttribution value; anything malformed is dropped (fail closed). */
127
+ export declare function sanitizeUsageAttribution(raw: unknown): UsageAttributionOptIn | undefined;
128
+ /**
129
+ * The opt-in that is in effect: present, for the uid of the current enabled
130
+ * default company, and given by the signed-in person. Mirrors the hq-cloud
131
+ * reader. No signed-in person means no opt-in.
132
+ */
133
+ export declare function effectiveUsageAttribution(cfg: Pick<WorkContextDeviceConfig, "defaultCompany" | "usageAttribution">, personUid: string | null | undefined): UsageAttributionOptIn | null;
84
134
  /** Injection points for assertSafeConfigPath (tests only). */
85
135
  export interface SafeConfigPathIo {
86
136
  platform?: NodeJS.Platform;
@@ -104,6 +154,22 @@ export declare function getDefaultCompany(deps: Pick<DeviceConfigDeps, "root">):
104
154
  /** A clear is a durable opt-out, not an invitation for automatic reactivation. */
105
155
  export declare function isDefaultCompanyDisabled(deps: Pick<DeviceConfigDeps, "root">): boolean;
106
156
  export declare function setDefaultCompany(slug: string, deps: DeviceConfigDeps): Promise<WorkContextDeviceConfig>;
157
+ /**
158
+ * Record or remove the usage-attribution opt-in for the current default
159
+ * company. Opting in requires an enabled, explicitly chosen default with a
160
+ * cmp_ uid that equals `companyUid` (the company the person was asked about);
161
+ * an automatic single-company default is never opted in.
162
+ */
163
+ export declare function setUsageAttribution(optIn: {
164
+ companyUid: string;
165
+ personUid: string;
166
+ } | null, deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
167
+ /**
168
+ * Drop the usage-attribution opt-in unless it was given by `personUid`. Called
169
+ * on login (identity may have changed) and logout (null: always drop). A
170
+ * config with no opt-in, or one already matching, is left untouched.
171
+ */
172
+ export declare function retainUsageAttributionForPerson(personUid: string | null | undefined, deps: Pick<DeviceConfigDeps, "root" | "now">): boolean;
107
173
  export declare function clearDefaultCompany(deps: DeviceConfigDeps): WorkContextDeviceConfig;
108
174
  /**
109
175
  * Read the persisted company mapping for a repo identity key (gap 4).
@@ -9,6 +9,10 @@ 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);
15
+ }
12
16
  const CONFIG_LOCK_TIMEOUT_MS = 5_000;
13
17
  const CONFIG_LOCK_RETRY_MS = 10;
14
18
  function waitForConfigLock(ms) {
@@ -146,6 +150,44 @@ function sanitizeRepoCompanyMap(raw) {
146
150
  }
147
151
  return Object.keys(out).length > 0 ? out : null;
148
152
  }
153
+ /** Parse a raw usageAttribution value; anything malformed is dropped (fail closed). */
154
+ export function sanitizeUsageAttribution(raw) {
155
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
156
+ return undefined;
157
+ const v = raw;
158
+ if (v.mode !== "default-company")
159
+ return undefined;
160
+ const companyUid = typeof v.companyUid === "string" ? v.companyUid.trim() : "";
161
+ const personUid = typeof v.personUid === "string" ? v.personUid.trim() : "";
162
+ const optedInAt = typeof v.optedInAt === "string" ? v.optedInAt.trim() : "";
163
+ if (!companyUid.startsWith("cmp_") ||
164
+ !isPersonUid(personUid) ||
165
+ !optedInAt ||
166
+ !Number.isFinite(Date.parse(optedInAt))) {
167
+ return undefined;
168
+ }
169
+ return { mode: "default-company", companyUid, personUid, optedInAt };
170
+ }
171
+ /**
172
+ * The opt-in that is in effect: present, for the uid of the current enabled
173
+ * default company, and given by the signed-in person. Mirrors the hq-cloud
174
+ * reader. No signed-in person means no opt-in.
175
+ */
176
+ export function effectiveUsageAttribution(cfg, personUid) {
177
+ const opt = cfg.usageAttribution;
178
+ if (!opt || !isPersonUid(personUid) || opt.personUid !== personUid)
179
+ return null;
180
+ const def = cfg.defaultCompany;
181
+ if (!opt || !def || def.enabled !== true || def.source === "disabled")
182
+ return null;
183
+ if (!def.uid || def.uid !== opt.companyUid)
184
+ return null;
185
+ return opt;
186
+ }
187
+ /** Keep `usageAttribution` next to `defaultCompany`, omitting it when absent. */
188
+ function usageAttributionField(value) {
189
+ return value ? { usageAttribution: value } : {};
190
+ }
149
191
  /**
150
192
  * Refuse a config path that is a symlink (every platform) or writable by
151
193
  * anyone other than the owner.
@@ -216,6 +258,7 @@ export function readDeviceConfig(deps) {
216
258
  defaultCompany: legacyNullDefault
217
259
  ? { slug: "", enabled: false, source: "disabled" }
218
260
  : raw.defaultCompany ?? null,
261
+ ...usageAttributionField(sanitizeUsageAttribution(raw.usageAttribution)),
219
262
  migrationCapability: raw.migrationCapability ?? null,
220
263
  repoCompanyMap: sanitizeRepoCompanyMap(raw.repoCompanyMap),
221
264
  defaultCompanyMemberships: sanitizeMembershipSnapshot(raw.defaultCompanyMemberships),
@@ -287,9 +330,36 @@ export async function setDefaultCompany(slug, deps) {
287
330
  };
288
331
  if (resolvedUid)
289
332
  defaultCompany.uid = resolvedUid;
333
+ const choice = deps.usageAttribution ?? "keep-if-same";
334
+ let usageAttribution;
335
+ const personUid = isPersonUid(deps.personUid) ? deps.personUid : undefined;
336
+ if (choice === "opt-in") {
337
+ if (!resolvedUid?.startsWith("cmp_")) {
338
+ throw new WorkContextError("UsageAttributionUnavailable", `Cannot count usage toward "${trimmed}": its company uid is unknown`);
339
+ }
340
+ if (!personUid) {
341
+ throw new WorkContextError("UsageAttributionUnavailable", `Cannot count usage toward "${trimmed}": the signed-in person could not be determined`);
342
+ }
343
+ usageAttribution = {
344
+ mode: "default-company",
345
+ companyUid: resolvedUid,
346
+ personUid,
347
+ optedInAt: now().toISOString(),
348
+ };
349
+ }
350
+ else if (choice === "keep-if-same") {
351
+ usageAttribution =
352
+ resolvedUid &&
353
+ personUid &&
354
+ prior.usageAttribution?.companyUid === resolvedUid &&
355
+ prior.usageAttribution.personUid === personUid
356
+ ? prior.usageAttribution
357
+ : undefined;
358
+ }
290
359
  const next = {
291
360
  schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
292
361
  defaultCompany,
362
+ ...usageAttributionField(usageAttribution),
293
363
  migrationCapability: prior.migrationCapability ?? null,
294
364
  repoCompanyMap: prior.repoCompanyMap ?? null,
295
365
  defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
@@ -300,6 +370,69 @@ export async function setDefaultCompany(slug, deps) {
300
370
  return next;
301
371
  });
302
372
  }
373
+ /**
374
+ * Record or remove the usage-attribution opt-in for the current default
375
+ * company. Opting in requires an enabled, explicitly chosen default with a
376
+ * cmp_ uid that equals `companyUid` (the company the person was asked about);
377
+ * an automatic single-company default is never opted in.
378
+ */
379
+ export function setUsageAttribution(optIn, deps) {
380
+ const now = deps.now ?? (() => new Date());
381
+ return withDeviceConfigLock(deps.root, () => {
382
+ const prior = readDeviceConfig(deps);
383
+ let usageAttribution;
384
+ if (optIn) {
385
+ const def = prior.defaultCompany;
386
+ if (!def ||
387
+ def.enabled !== true ||
388
+ def.source !== "chosen" ||
389
+ !def.uid?.startsWith("cmp_") ||
390
+ def.uid !== optIn.companyUid ||
391
+ !isPersonUid(optIn.personUid)) {
392
+ throw new WorkContextError("UsageAttributionUnavailable", "Usage can only be counted toward a default company you chose on this device");
393
+ }
394
+ usageAttribution = {
395
+ mode: "default-company",
396
+ companyUid: def.uid,
397
+ personUid: optIn.personUid,
398
+ optedInAt: now().toISOString(),
399
+ };
400
+ }
401
+ const next = {
402
+ schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
403
+ defaultCompany: prior.defaultCompany ?? null,
404
+ ...usageAttributionField(usageAttribution),
405
+ migrationCapability: prior.migrationCapability ?? null,
406
+ repoCompanyMap: prior.repoCompanyMap ?? null,
407
+ defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
408
+ repairHeldWithDefault: prior.repairHeldWithDefault === true,
409
+ updatedAt: now().toISOString(),
410
+ };
411
+ writeDeviceConfig({ root: deps.root }, next);
412
+ return next;
413
+ });
414
+ }
415
+ /**
416
+ * Drop the usage-attribution opt-in unless it was given by `personUid`. Called
417
+ * on login (identity may have changed) and logout (null: always drop). A
418
+ * config with no opt-in, or one already matching, is left untouched.
419
+ */
420
+ export function retainUsageAttributionForPerson(personUid, deps) {
421
+ if (!fs.existsSync(workContextConfigPath(deps.root)))
422
+ return false;
423
+ return withDeviceConfigLock(deps.root, () => {
424
+ const prior = readDeviceConfig(deps);
425
+ const opt = prior.usageAttribution;
426
+ if (!opt)
427
+ return false;
428
+ if (isPersonUid(personUid) && opt.personUid === personUid)
429
+ return false;
430
+ const now = deps.now ?? (() => new Date());
431
+ const { usageAttribution: _dropped, ...rest } = prior;
432
+ writeDeviceConfig({ root: deps.root }, { ...rest, updatedAt: now().toISOString() });
433
+ return true;
434
+ });
435
+ }
303
436
  export function clearDefaultCompany(deps) {
304
437
  const now = deps.now ?? (() => new Date());
305
438
  return withDeviceConfigLock(deps.root, () => {
@@ -307,6 +440,8 @@ export function clearDefaultCompany(deps) {
307
440
  const next = {
308
441
  schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
309
442
  defaultCompany: { slug: "", enabled: false, source: "disabled" },
443
+ // usageAttribution is intentionally omitted: clearing the default also
444
+ // withdraws the opt-in to count usage toward it.
310
445
  migrationCapability: prior.migrationCapability ?? null,
311
446
  repoCompanyMap: prior.repoCompanyMap ?? null,
312
447
  defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
@@ -355,6 +490,7 @@ export function setRepoCompany(key, mapping, deps) {
355
490
  const next = {
356
491
  schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
357
492
  defaultCompany: prior.defaultCompany ?? null,
493
+ ...usageAttributionField(prior.usageAttribution),
358
494
  migrationCapability: prior.migrationCapability ?? null,
359
495
  repoCompanyMap: nextMap,
360
496
  defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
@@ -373,6 +509,7 @@ export function recordMigrationCapabilitySnapshot(snapshot, deps) {
373
509
  const next = {
374
510
  schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
375
511
  defaultCompany: prior.defaultCompany ?? null,
512
+ ...usageAttributionField(prior.usageAttribution),
376
513
  migrationCapability: {
377
514
  unlocked: snapshot.unlocked,
378
515
  offline: snapshot.offline,
@@ -414,6 +551,7 @@ export function recordDefaultCompanyMemberships(snapshot, deps) {
414
551
  const next = {
415
552
  schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
416
553
  defaultCompany: prior.defaultCompany ?? null,
554
+ ...usageAttributionField(prior.usageAttribution),
417
555
  migrationCapability: prior.migrationCapability ?? null,
418
556
  repoCompanyMap: prior.repoCompanyMap ?? null,
419
557
  defaultCompanyMemberships: {
@@ -435,6 +573,7 @@ export function invalidateDefaultCompanyMemberships(deps) {
435
573
  const next = {
436
574
  schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
437
575
  defaultCompany: prior.defaultCompany ?? null,
576
+ ...usageAttributionField(prior.usageAttribution),
438
577
  migrationCapability: prior.migrationCapability ?? null,
439
578
  repoCompanyMap: prior.repoCompanyMap ?? null,
440
579
  defaultCompanyMemberships: null,
@@ -468,6 +607,8 @@ export function setAutoDefaultCompany(membership, deps) {
468
607
  enabled: true,
469
608
  source: "auto_single",
470
609
  },
610
+ // usageAttribution is intentionally omitted: an automatic default is
611
+ // never opted in to usage attribution.
471
612
  migrationCapability: prior.migrationCapability ?? null,
472
613
  repoCompanyMap: prior.repoCompanyMap ?? null,
473
614
  defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
@@ -485,6 +626,7 @@ export function setRepairHeldWithDefault(enabled, deps) {
485
626
  const next = {
486
627
  schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
487
628
  defaultCompany: prior.defaultCompany ?? null,
629
+ ...usageAttributionField(prior.usageAttribution),
488
630
  migrationCapability: prior.migrationCapability ?? null,
489
631
  repoCompanyMap: prior.repoCompanyMap ?? null,
490
632
  defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Signed-in person identity for the usage-attribution opt-in.
3
+ *
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.
11
+ */
12
+ export declare function personUidFromTokens(tokens: {
13
+ idToken?: string;
14
+ accessToken?: string;
15
+ } | null | undefined): string | null;
16
+ //# sourceMappingURL=person-identity.d.ts.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Signed-in person identity for the usage-attribution opt-in.
3
+ *
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.
11
+ */
12
+ import { isPersonUid } from "./config.js";
13
+ function claimsOf(token) {
14
+ if (typeof token !== "string")
15
+ return null;
16
+ const part = token.split(".")[1];
17
+ if (!part)
18
+ return null;
19
+ try {
20
+ const parsed = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
21
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
22
+ ? parsed
23
+ : null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export function personUidFromTokens(tokens) {
30
+ if (!tokens)
31
+ return null;
32
+ for (const token of [tokens.idToken, tokens.accessToken]) {
33
+ const uid = claimsOf(token)?.["custom:entityUid"];
34
+ if (isPersonUid(uid))
35
+ return uid;
36
+ }
37
+ return null;
38
+ }
39
+ //# sourceMappingURL=person-identity.js.map
@@ -32,7 +32,7 @@
32
32
  */
33
33
  import { isExpiring, loadCachedTokens } from "./cognito-session.js";
34
34
  import { type VersionInfo } from "./feedback-versions.js";
35
- import { type ClientHealthArch, type ClientHealthHeartbeat, type ClientHealthLocalFilesOverview, type ClientHealthPlatform } from "./client-health-contract.js";
35
+ import { type ClientHealthArch, type ClientHealthFailureReason, type ClientHealthHeartbeat, type ClientHealthLocalFilesOverview, type ClientHealthPlatform } from "./client-health-contract.js";
36
36
  export declare const CLIENT_HEALTH_STATE_FILE = "cli-client-health.json";
37
37
  /**
38
38
  * Single-flight marker for the journal observation below. Held only for the
@@ -141,6 +141,8 @@ export interface BuildCliHeartbeatInput {
141
141
  now: Date;
142
142
  platform?: ClientHealthPlatform | null;
143
143
  arch?: ClientHealthArch | null;
144
+ /** Closed, typed reason from the terminal sync runner; never free-form text. */
145
+ failureReason?: ClientHealthFailureReason;
144
146
  /**
145
147
  * Bounded local log/journal file facts (US-016). Only ever attached for
146
148
  * `sync_success` / `sync_failure` kinds — support asked for this on the
@@ -148,6 +150,8 @@ export interface BuildCliHeartbeatInput {
148
150
  */
149
151
  localFilesOverview?: ClientHealthLocalFilesOverview | null;
150
152
  }
153
+ /** Classify sync failures using typed error identity/code fields only. */
154
+ export declare function classifySyncFailure(err: unknown): ClientHealthFailureReason;
151
155
  /**
152
156
  * Build one contract-valid CLI heartbeat, or null when this environment
153
157
  * cannot be represented in the closed contract enums (unknown platform/arch)
@@ -184,7 +188,7 @@ export interface ClientHealthDeps {
184
188
  * ignores its abort signal) must never delay a command past the bound. On
185
189
  * timeout the pending work is abandoned silently.
186
190
  */
187
- export declare function contributeClientHealth(kind: ClientHealthEventKind, deps?: ClientHealthDeps): Promise<void>;
191
+ export declare function contributeClientHealth(kind: ClientHealthEventKind, deps?: ClientHealthDeps, failureReason?: ClientHealthFailureReason): Promise<void>;
188
192
  /**
189
193
  * Fire-once-per-process invocation heartbeat (commander preAction seam, next
190
194
  * to `emitCliSessionStarted`). Reports the real CLI + Core versions, platform,
@@ -194,7 +198,7 @@ export declare function reportCliClientHealthInvocation(deps?: ClientHealthDeps)
194
198
  export interface SyncHealthReport {
195
199
  /** Report the terminal outcome. Bounded, swallowed, safe to await. */
196
200
  succeeded(): Promise<void>;
197
- failed(): Promise<void>;
201
+ failed(reason?: ClientHealthFailureReason): Promise<void>;
198
202
  }
199
203
  /**
200
204
  * Report a CLI sync command run: an attempt heartbeat now (fire-and-forget)
@@ -37,6 +37,7 @@ import { getStateDir, listJournals } from "@indigoai-us/hq-cloud";
37
37
  import { isExpiring, isMachineIdentity, loadCachedTokens, } from "./cognito-session.js";
38
38
  import { vaultApiFetch } from "./vault-api.js";
39
39
  import { collectVersions } from "./feedback-versions.js";
40
+ import { isAuthError } from "./auth-error.js";
40
41
  import { CLIENT_HEALTH_CONTRACT_VERSION, parseClientHealthHeartbeat, } from "./client-health-contract.js";
41
42
  import { collectLocalFilesOverview } from "./local-files-overview.js";
42
43
  /** Same bound as CLI telemetry: a heartbeat may never stall a command. */
@@ -314,6 +315,25 @@ function acquireJournalObservationLock(stateDir, nowMs, staleMs) {
314
315
  }
315
316
  };
316
317
  }
318
+ /** Classify sync failures using typed error identity/code fields only. */
319
+ export function classifySyncFailure(err) {
320
+ if (isAuthError(err))
321
+ return "AUTH_EXPIRED";
322
+ let current = err;
323
+ for (let depth = 0; depth < 3; depth += 1) {
324
+ if (current === null || typeof current !== "object")
325
+ break;
326
+ const value = current;
327
+ if (value.code === "ENOSPC" || value.code === "EDQUOT") {
328
+ return "DISK_FULL";
329
+ }
330
+ if (value.code === "EACCES" || value.code === "EPERM") {
331
+ return "PERMISSION_DENIED";
332
+ }
333
+ current = value.cause;
334
+ }
335
+ return "RUNNER_FAILED";
336
+ }
317
337
  /**
318
338
  * Build one contract-valid CLI heartbeat, or null when this environment
319
339
  * cannot be represented in the closed contract enums (unknown platform/arch)
@@ -388,6 +408,9 @@ export function buildCliHeartbeat(input) {
388
408
  if (input.state.lastSyncAttemptAt !== undefined) {
389
409
  heartbeat.lastSyncAttemptAt = input.state.lastSyncAttemptAt;
390
410
  }
411
+ if (input.kind === "sync_failure" && input.failureReason !== undefined) {
412
+ heartbeat.failureReason = input.failureReason;
413
+ }
391
414
  if (lastSyncSuccessAt !== null) {
392
415
  heartbeat.lastSyncSuccessAt = lastSyncSuccessAt;
393
416
  }
@@ -435,10 +458,10 @@ function settleWithin(work, ms) {
435
458
  * ignores its abort signal) must never delay a command past the bound. On
436
459
  * timeout the pending work is abandoned silently.
437
460
  */
438
- export async function contributeClientHealth(kind, deps = {}) {
439
- await settleWithin(contributeClientHealthUnbounded(kind, deps), deps.timeoutMs ?? CLIENT_HEALTH_TIMEOUT_MS);
461
+ export async function contributeClientHealth(kind, deps = {}, failureReason) {
462
+ await settleWithin(contributeClientHealthUnbounded(kind, deps, failureReason), deps.timeoutMs ?? CLIENT_HEALTH_TIMEOUT_MS);
440
463
  }
441
- async function contributeClientHealthUnbounded(kind, deps) {
464
+ async function contributeClientHealthUnbounded(kind, deps, failureReason) {
442
465
  try {
443
466
  const now = (deps.now ?? (() => new Date()))();
444
467
  const stateDir = (deps.stateDir ?? getStateDir)();
@@ -546,6 +569,7 @@ async function contributeClientHealthUnbounded(kind, deps) {
546
569
  journalLastSyncAt,
547
570
  now,
548
571
  localFilesOverview,
572
+ failureReason,
549
573
  });
550
574
  if (!heartbeat)
551
575
  return;
@@ -587,12 +611,12 @@ export function reportCliClientHealthInvocation(deps) {
587
611
  */
588
612
  export function beginSyncHealthReport(deps = {}) {
589
613
  const attempt = contributeClientHealth("sync_attempt", deps);
590
- const finish = (kind) => attempt
591
- .then(() => contributeClientHealth(kind, deps))
614
+ const finish = (kind, failureReason) => attempt
615
+ .then(() => contributeClientHealth(kind, deps, failureReason))
592
616
  .catch(() => undefined);
593
617
  return {
594
618
  succeeded: () => finish("sync_success"),
595
- failed: () => finish("sync_failure"),
619
+ failed: (reason) => finish("sync_failure", reason),
596
620
  };
597
621
  }
598
622
  //# sourceMappingURL=client-health.js.map
@@ -1,4 +1,6 @@
1
1
  export declare const PACKAGE_USE_LEASE_HANDOFF_ENV = "HQ_CLI_PACKAGE_USE_LEASE_HANDOFF";
2
+ export declare const PACKAGE_USE_LEASE_PURPOSES: readonly ["command", "resident-preload", "mcp-serve", "hook", "daemon"];
3
+ export type PackageUseLeasePurpose = (typeof PACKAGE_USE_LEASE_PURPOSES)[number];
2
4
  export declare class PackageUseUpdatePendingError extends Error {
3
5
  constructor(message: string);
4
6
  }
@@ -10,7 +12,10 @@ export interface PackageUseLeaseRecord {
10
12
  pid: number;
11
13
  start_time_ms: number;
12
14
  hq_version: string;
15
+ purpose?: PackageUseLeasePurpose;
13
16
  }
17
+ /** Map only fixed command words to a bounded lease purpose; never persist argv values. */
18
+ export declare function packageUseLeasePurposeForArgv(argv: readonly string[]): PackageUseLeasePurpose;
14
19
  export interface PackageUseLeaseHandoff {
15
20
  readonly envValue: string;
16
21
  waitForChild(pid: number, childAlive: () => boolean): Promise<boolean>;
@@ -20,7 +25,7 @@ export declare function npmPrefix(root: string, platform?: NodeJS.Platform): str
20
25
  export declare function packageUseLeaseDirectory(prefix: string, stateDirectory?: string): string;
21
26
  export declare function packageUseUpdateRequestPath(prefix: string, stateDirectory?: string): string;
22
27
  declare function waitForUpdateRequest(requestPath: string, timeoutMs: number): void;
23
- export declare function acquirePackageUseLease(prefix: string, hqVersion: string, stateDirectory?: string): PackageUseLease;
28
+ export declare function acquirePackageUseLease(prefix: string, hqVersion: string, stateDirectory?: string, purpose?: PackageUseLeasePurpose): PackageUseLease;
24
29
  /**
25
30
  * Create a bounded, per-reexec rendezvous in the current lease directory.
26
31
  * The child confirms readiness only after publishing its own atomic lease.
@@ -29,7 +34,7 @@ export declare function createPackageUseLeaseHandoff(lease: PackageUseLease, tim
29
34
  export declare function createCurrentPackageUseLeaseHandoff(timeoutMs?: number): PackageUseLeaseHandoff | null;
30
35
  /** Publish this process's lease identity to a parent waiting in the same prefix. */
31
36
  export declare function publishPackageUseLeaseHandoff(lease: PackageUseLease, env?: NodeJS.ProcessEnv): boolean;
32
- export declare function acquireCurrentPackageUseLease(): PackageUseLease | null;
37
+ export declare function acquireCurrentPackageUseLease(argv?: readonly string[]): PackageUseLease | null;
33
38
  /** Release this process's package-use lease after a long-running command has loaded its package modules. */
34
39
  export declare function releaseCurrentPackageUseLease(): void;
35
40
  /** The lease remains held through command execution and is released on process exit. */
@@ -8,12 +8,47 @@ const CLI_NAME = "@indigoai-us/hq-cli";
8
8
  const UPDATE_REQUEST = "update.pending.json";
9
9
  export const PACKAGE_USE_LEASE_HANDOFF_ENV = "HQ_CLI_PACKAGE_USE_LEASE_HANDOFF";
10
10
  const HANDOFF_TIMEOUT_MS = 2_000;
11
+ export const PACKAGE_USE_LEASE_PURPOSES = [
12
+ "command",
13
+ "resident-preload",
14
+ "mcp-serve",
15
+ "hook",
16
+ "daemon",
17
+ ];
11
18
  export class PackageUseUpdatePendingError extends Error {
12
19
  constructor(message) {
13
20
  super(message);
14
21
  this.name = "PackageUseUpdatePendingError";
15
22
  }
16
23
  }
24
+ /** Map only fixed command words to a bounded lease purpose; never persist argv values. */
25
+ export function packageUseLeasePurposeForArgv(argv) {
26
+ const args = argv.slice(2);
27
+ if (args[0] === "mcp" && args[1] === "serve")
28
+ return "mcp-serve";
29
+ if ((args[0] === "daemon" && args[1] === "run") ||
30
+ (args[0] === "mesh" && args[1] === "daemon" && args[2] === "run"))
31
+ return "daemon";
32
+ if (args[0] === "core" && ["hq-session", "checkpoint-stop-gate"].includes(args[1] ?? "")) {
33
+ return "hook";
34
+ }
35
+ const hasOption = (...options) => options.some((option) => args.includes(option));
36
+ if (args[0] === "login" ||
37
+ (args[0] === "auth" && args[1] === "login") ||
38
+ (args[0] === "agents" && ["kit", "mcp"].includes(args[1] ?? "")) ||
39
+ (args[0] === "agents" && ["terminal", "message"].includes(args[1] ?? "") &&
40
+ (args[1] === "terminal" || hasOption("--wait"))) ||
41
+ (args[0] === "bot" && (args[1] === "run" || (args[1] === "logs" && hasOption("--follow", "-f")))) ||
42
+ (args[0] === "daemon" && args[1] === "logs" && hasOption("--follow", "-f")) ||
43
+ (args[0] === "integrations" && args[1] === "connect") ||
44
+ (args[0] === "lanes" && ["watch", "message", "resume"].includes(args[1] ?? "")) ||
45
+ (args[0] === "monitor" && ["wait", "_run"].includes(args[1] ?? "")) ||
46
+ (args[0] === "pack" && args[1] === "install") ||
47
+ (args[0] === "pkg" && args[1] === "update") ||
48
+ (args[0] === "secrets" && args[1] === "sandbox"))
49
+ return "resident-preload";
50
+ return "command";
51
+ }
17
52
  let currentPackageUseLease = null;
18
53
  function errorLabel(error) {
19
54
  if (error && typeof error === "object") {
@@ -131,7 +166,7 @@ function waitForUpdateRequest(requestPath, timeoutMs) {
131
166
  Atomics.wait(sleeper, 0, 0, 100);
132
167
  }
133
168
  }
134
- export function acquirePackageUseLease(prefix, hqVersion, stateDirectory) {
169
+ export function acquirePackageUseLease(prefix, hqVersion, stateDirectory, purpose = "command") {
135
170
  const directory = packageUseLeaseDirectory(prefix, stateDirectory);
136
171
  mkdirSync(directory, { recursive: true, mode: 0o700 });
137
172
  const requestPath = path.join(directory, UPDATE_REQUEST);
@@ -139,6 +174,7 @@ export function acquirePackageUseLease(prefix, hqVersion, stateDirectory) {
139
174
  pid: process.pid,
140
175
  start_time_ms: Math.floor(performance.timeOrigin),
141
176
  hq_version: hqVersion,
177
+ purpose,
142
178
  };
143
179
  const leasePath = path.join(directory, `${record.pid}-${record.start_time_ms}.json`);
144
180
  const tempPath = `${leasePath}.${Math.random().toString(36).slice(2)}.tmp`;
@@ -272,7 +308,7 @@ function exists(file) {
272
308
  throw error;
273
309
  }
274
310
  }
275
- export function acquireCurrentPackageUseLease() {
311
+ export function acquireCurrentPackageUseLease(argv = process.argv) {
276
312
  const pkg = findCliPackage(path.dirname(fileURLToPath(import.meta.url)));
277
313
  if (!pkg || isPnpmGlobal(pkg.root) || isBunGlobal(pkg.root))
278
314
  return null;
@@ -285,7 +321,7 @@ export function acquireCurrentPackageUseLease() {
285
321
  if (nodeModules > 0 && parts[nodeModules - 1] !== "lib")
286
322
  return null;
287
323
  }
288
- const lease = acquirePackageUseLease(prefix, pkg.version);
324
+ const lease = acquirePackageUseLease(prefix, pkg.version, undefined, packageUseLeasePurposeForArgv(argv));
289
325
  currentPackageUseLease = lease;
290
326
  publishPackageUseLeaseHandoff(lease);
291
327
  return lease;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.343.1",
3
+ "version": "5.344.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {