@indigoai-us/hq-cli 5.343.0 → 5.344.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/command-catalog.generated.d.ts +15 -4
  3. package/dist/command-catalog.generated.js +19 -4
  4. package/dist/commands/cloud.js +2 -2
  5. package/dist/commands/daemon.js +2 -11
  6. package/dist/commands/dm.js +10 -1
  7. package/dist/commands/lanes-shared.d.ts +1 -0
  8. package/dist/commands/lanes.d.ts +1 -0
  9. package/dist/commands/lanes.js +11 -0
  10. package/dist/commands/login.js +12 -1
  11. package/dist/commands/logout.js +10 -0
  12. package/dist/commands/mesh.d.ts +27 -1
  13. package/dist/commands/mesh.js +123 -12
  14. package/dist/lib/agent-kit/mcp/tools.js +1 -1
  15. package/dist/lib/bot/api.d.ts +1 -0
  16. package/dist/lib/bot/api.js +2 -0
  17. package/dist/lib/bot/progress.d.ts +12 -14
  18. package/dist/lib/bot/progress.js +29 -40
  19. package/dist/lib/bot/run.js +24 -24
  20. package/dist/lib/core-utils/sentry-report.d.ts +1 -1
  21. package/dist/lib/core-utils/sentry-report.js +18 -1
  22. package/dist/lib/daemon/command-call.d.ts +9 -0
  23. package/dist/lib/daemon/command-call.js +18 -0
  24. package/dist/lib/lanes/outpost-dispatch.d.ts +1 -0
  25. package/dist/lib/lanes/outpost-dispatch.js +1 -0
  26. package/dist/lib/work-context/config.d.ts +66 -0
  27. package/dist/lib/work-context/config.js +142 -0
  28. package/dist/lib/work-context/person-identity.d.ts +16 -0
  29. package/dist/lib/work-context/person-identity.js +39 -0
  30. package/dist/utils/cli-telemetry.js +5 -1
  31. package/dist/utils/client-health.d.ts +7 -3
  32. package/dist/utils/client-health.js +30 -6
  33. package/dist/utils/install-tree-torn.d.ts +16 -13
  34. package/dist/utils/install-tree-torn.js +58 -26
  35. package/dist/utils/resident-package-preload.d.ts +1 -1
  36. package/dist/utils/resident-package-preload.js +18 -0
  37. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.344.0] — 2026-10-04
6
+ - Include a bounded reason in sync-failure client-health reports.
7
+ - 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.
8
+
9
+ ## [5.343.1] — 2026-10-04
10
+
11
+ - Include the bounded hq-cli version in `cli_feature_used` telemetry so feature-use rows can be grouped by release.
12
+
13
+ - Release the HQ CLI package-use lease before hq daemon call waits for its daemon response, after preloading the request protocol.
14
+ - Release the package-use lease after Outpost lane message and resume requests are submitted and their result-wait imports are preloaded.
15
+ - Preserve human, agent, and both audience tags when CLI tools and local bots post or reply in channels.
16
+ - Wait for a live installer to finish before retrying a failed CLI import when the install tree is still in flight.
17
+ - Preserve bounded master-hook slow-phase and per-phase timeout attribution in Sentry reports.
18
+
5
19
  ## [5.343.0] — 2026-10-04
6
20
  - `hq company --company <slug> settings set --work-registry-enabled <true|false>` and `--work-mesh-live-enabled <true|false>` let a company owner turn work tracking on or off. Work tracking needs both switches; after either is set the command says whether work tracking is on and prints the exact command for the switch that is still off. `hq company --company <slug> settings get` now also shows both switches for an owner or admin.
7
21
  - `hq mesh context requeue` now also re-sends, by default, sessions that were refused while work tracking was off. `hq doctor` and `hq mesh daemon doctor` explain those refusals in plain words.
@@ -3204,7 +3204,7 @@ export declare const COMMAND_CATALOG: readonly [{
3204
3204
  readonly hidden: false;
3205
3205
  }, {
3206
3206
  readonly flags: "--audience <human|agent|both>";
3207
- readonly description: "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both (1:1 DMs only)";
3207
+ readonly description: "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both";
3208
3208
  readonly hidden: false;
3209
3209
  }];
3210
3210
  readonly subcommands: readonly [];
@@ -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;
@@ -3874,7 +3874,7 @@ export const COMMAND_CATALOG = [
3874
3874
  },
3875
3875
  {
3876
3876
  "flags": "--audience <human|agent|both>",
3877
- "description": "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both (1:1 DMs only)",
3877
+ "description": "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both",
3878
3878
  "hidden": false
3879
3879
  }
3880
3880
  ],
@@ -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);
@@ -29,6 +29,7 @@ import { MACHINE_TYPE_MARKER_PATH, detectMachineType, homeFromPasswd, } from "..
29
29
  import { daemonPaths } from "../lib/daemon/paths.js";
30
30
  import { processCommandLine } from "../lib/daemon/lock.js";
31
31
  import { writeControlRequest } from "../lib/daemon/requests.js";
32
+ import { runDaemonCall } from "../lib/daemon/command-call.js";
32
33
  import { isSyncRunnerCommandLine, readWatchRunnerOwnerStatus, watchRunnerOwnerStatusPath } from "../lib/daemon/sync-runner.js";
33
34
  import { getSyncModeForCompany, setSyncMode } from "./sync-mode.js";
34
35
  import { requireAnywhereRuntime } from "./anywhere-runtime-flag.js";
@@ -857,17 +858,7 @@ export function registerDaemonCommand(program, over = {}) {
857
858
  const timeoutMs = Number(opts.timeout ?? (op === "policy.check" ? "40000" : "20000"));
858
859
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
859
860
  throw new Error("--timeout must be a positive number of milliseconds");
860
- const { hqdRequest, hqdSocketPath, HqdClientError } = await import("../lib/daemon/protocol.js");
861
- try {
862
- const result = await hqdRequest(op, args, { socketPath: hqdSocketPath(d.home, d.env), timeoutMs });
863
- d.out(JSON.stringify(result, null, 2));
864
- }
865
- catch (err) {
866
- if (!(err instanceof HqdClientError))
867
- throw err;
868
- d.out(JSON.stringify({ error: { code: err.code, message: err.message } }, null, 2));
869
- process.exitCode = 1;
870
- }
861
+ await runResidentCommandWithPackageLease("daemon-call", () => runDaemonCall(op, args, timeoutMs, d));
871
862
  }));
872
863
  daemon
873
864
  .command("status")
@@ -614,11 +614,20 @@ async function runChannelSend(channelName, message, opts) {
614
614
  // Send the human-readable form: the quotes only existed for parsing.
615
615
  if (mentions.length > 0)
616
616
  body = stripMentionQuotes(body);
617
+ let audience;
618
+ if (opts.audience !== undefined && opts.audience !== "") {
619
+ if (!DM_AUDIENCE_VALUES.includes(opts.audience)) {
620
+ throw new Error(`Invalid --audience '${opts.audience}': must be human, agent, or both.`);
621
+ }
622
+ audience = opts.audience;
623
+ }
617
624
  const payload = { body };
618
625
  if (mentions.length > 0)
619
626
  payload.mentions = mentions;
620
627
  if (rootEventId)
621
628
  payload.rootEventId = rootEventId;
629
+ if (audience)
630
+ payload.audience = audience;
622
631
  const sendRes = await vaultApiFetch({
623
632
  token,
624
633
  path: `/v1/notify/channels/${encodeURIComponent(channel.channelId)}/messages`,
@@ -1327,7 +1336,7 @@ export function registerDmCommand(program) {
1327
1336
  .option("--details-file <path>", "Read the details from a file (1:1 DMs only)")
1328
1337
  .option("--at <iso>", "Schedule delivery at an ISO8601 time (store-and-forward; delivered within ~60s of the time; 1:1 DMs only)")
1329
1338
  .option("--in <duration>", "Schedule delivery after a relative delay: 30s, 10m, 2h, 1d (1:1 DMs only)")
1330
- .option("--audience <human|agent|both>", "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both (1:1 DMs only)")
1339
+ .option("--audience <human|agent|both>", "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both")
1331
1340
  .action(async (recipient, message, opts) => {
1332
1341
  await runDmSend(recipient, message, opts);
1333
1342
  });
@@ -272,6 +272,7 @@ export interface Ctx {
272
272
  command: string;
273
273
  timeoutSeconds?: number;
274
274
  waitMs?: number;
275
+ beforeWait?: () => Promise<void>;
275
276
  }) => Promise<OutpostDispatchResult>;
276
277
  backfillOutpostIdentity: (expectedId: string) => "created" | "unchanged";
277
278
  fetchText: (url: string) => Promise<string>;
@@ -86,6 +86,7 @@ export interface LanesCommandDeps {
86
86
  command: string;
87
87
  timeoutSeconds?: number;
88
88
  waitMs?: number;
89
+ beforeWait?: () => Promise<void>;
89
90
  }) => Promise<OutpostDispatchResult>;
90
91
  /** Test seam for the atomic, non-overwriting marker repair. */
91
92
  backfillOutpostIdentity?: (expectedId: string) => "created" | "unchanged";
@@ -4317,11 +4317,22 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options, behavior =
4317
4317
  let dispatched;
4318
4318
  let grantContextRejected = false;
4319
4319
  let failedResumeOptionRejected = false;
4320
+ let outpostWaitLeasePreparationStarted = false;
4320
4321
  try {
4321
4322
  const dispatchCommand = (contexts) => ctx.dispatchOutpostExec({
4322
4323
  outpostId,
4323
4324
  timeoutSeconds: Math.ceil(waitMs / 1000),
4324
4325
  waitMs,
4326
+ ...(action === "message" || action === "resume"
4327
+ ? {
4328
+ beforeWait: async () => {
4329
+ if (outpostWaitLeasePreparationStarted)
4330
+ return;
4331
+ outpostWaitLeasePreparationStarted = true;
4332
+ await runResidentCommandWithPackageLease("outpost-wait", async () => undefined);
4333
+ },
4334
+ }
4335
+ : {}),
4325
4336
  command: buildOutpostLaneCommand({
4326
4337
  action: action === "grant"
4327
4338
  ? "grant-target"
@@ -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'));
@@ -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
@@ -237,7 +237,7 @@ export function buildAgentMcpTools(clients) {
237
237
  const token = await clients.getToken();
238
238
  const res = await clients.apiJson(token, `/v1/notify/channels/${encodeURIComponent(to)}/messages`, {
239
239
  method: "POST",
240
- body: { body: message },
240
+ body: { body: message, ...(audience ? { audience } : {}) },
241
241
  });
242
242
  if (res.status < 200 || res.status >= 300) {
243
243
  return errorResult(`Channel send failed (${res.status}): ${describeError(res.body)}`);
@@ -239,6 +239,7 @@ export declare class BotApi {
239
239
  channelId: string;
240
240
  body: string;
241
241
  rootEventId?: string;
242
+ audience?: "human" | "agent" | "both";
242
243
  }): Promise<{
243
244
  eventId?: string;
244
245
  createdAt?: string;
@@ -195,6 +195,8 @@ export class BotApi {
195
195
  const payload = { body: input.body };
196
196
  if (input.rootEventId)
197
197
  payload.rootEventId = input.rootEventId;
198
+ if (input.audience)
199
+ payload.audience = input.audience;
198
200
  const { body } = await this.call(`/v1/notify/channels/${encodeURIComponent(input.channelId)}/messages`, { method: "POST", body: payload });
199
201
  return body;
200
202
  }