@indigoai-us/hq-cli 5.345.166 → 5.345.169

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 (39) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/assets/scaffold/core/scripts/hq-delegate-send.sh +1 -1
  3. package/dist/command-catalog.generated.d.ts +6 -2
  4. package/dist/command-catalog.generated.js +7 -2
  5. package/dist/commands/access.js +2 -0
  6. package/dist/commands/agents.js +2 -2
  7. package/dist/commands/core-checkpoint-native.js +30 -10
  8. package/dist/commands/core-delegate-w1b.js +2 -1
  9. package/dist/commands/core-dm-bind-native.js +3 -1
  10. package/dist/commands/dm.d.ts +13 -2
  11. package/dist/commands/dm.js +42 -9
  12. package/dist/commands/feedback.js +11 -3
  13. package/dist/lib/agent-kit/mcp/tools.js +5 -6
  14. package/dist/lib/doctor/checks/sync-health.d.ts +7 -1
  15. package/dist/lib/doctor/checks/sync-health.js +67 -2
  16. package/dist/lib/doctor/fix/apply.d.ts +40 -4
  17. package/dist/lib/doctor/fix/apply.js +183 -69
  18. package/dist/lib/doctor/fix/process-identity.d.ts +13 -0
  19. package/dist/lib/doctor/fix/process-identity.js +34 -0
  20. package/dist/lib/doctor/fix/refresh-sync-handoff.d.ts +201 -0
  21. package/dist/lib/doctor/fix/refresh-sync-handoff.js +752 -0
  22. package/dist/lib/doctor/fix/refresh-sync-outcome.d.ts +1 -1
  23. package/dist/lib/doctor/fix/refresh-sync-outcome.js +13 -0
  24. package/dist/lib/doctor/types.d.ts +1 -1
  25. package/dist/lib/doctor/types.js +2 -0
  26. package/dist/lib/notify-recipient.d.ts +15 -0
  27. package/dist/lib/notify-recipient.js +15 -0
  28. package/dist/lib/secret-scan/repo-config.d.ts +8 -2
  29. package/dist/lib/secret-scan/repo-config.js +12 -6
  30. package/dist/lib/secret-scan/staged.d.ts +6 -0
  31. package/dist/lib/secret-scan/staged.js +21 -3
  32. package/dist/sentry-before-send.js +13 -0
  33. package/dist/utils/breadcrumb-buffer.js +15 -4
  34. package/dist/utils/feedback-diagnostics.js +5 -2
  35. package/dist/utils/feedback-logs.d.ts +7 -0
  36. package/dist/utils/feedback-logs.js +37 -3
  37. package/dist/utils/scrub-diagnostics.d.ts +4 -0
  38. package/dist/utils/scrub-diagnostics.js +95 -0
  39. package/package.json +7 -7
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.169] — 2026-10-24
6
+ - Sentry error reports now redact CloudFront signed-URL params (`Policy`, `Key-Pair-Id`) and the `AWSAccessKeyId` and `GoogleAccessId` ids when they appear as structured keys in event data, for example a parsed query under `extra`. The feedback diagnostics scrubber already redacted them (#2177); the Sentry scrubber caught them only inside strings. The shared structured-key cases now run against the Sentry scrubber too.
7
+ - A debounced automatic checkpoint is now queued for the next checkpoint sibling. Before this, `hq core checkpoint` wrote the `-auto-` thread when the per-session debounce skipped the sibling, but it never queued the payload, so no sibling ever filled the thread in. The payload is now added to the pending queue once, and the next sibling run picks it up. The debounce still limits how many siblings start. When the pending queue goes over its cap of 50, a message on stderr now gives the number of oldest payloads dropped. Feedback a2a5c2c3-5ea0-4e1f-8155-1f39ce6ac750.
8
+
9
+ - `hq feedback` now redacts presigned URL signatures, session tokens, bearer tokens and other credentials from the diagnostics it attaches. Before this fix, recent Sentry breadcrumbs were copied into diagnostics without scrubbing, so an S3 presigned URL's X-Amz-Security-Token could reach the stored feedback row. Diagnostics passed in by callers are scrubbed too, and the inline log tails and the S3 log bundle now also catch bare, URL-encoded, CloudFront, GCS and Azure signed URL parameters.
10
+ - `hq doctor --fix` no longer starts a second background sync pull while a pull it stopped at its time limit is still shutting down. It takes the pull-start lock from another process only when that process has exited or its process id now belongs to a different process.
11
+ - `hq doctor --fix` can now refresh sync journals on drives without hard links (exFAT, FAT, some network drives). `hq doctor` warns, naming the process, when a journal refresh lock has been held for more than 10 minutes.
12
+ - `hq doctor --fix` no longer kills a slow sync journal pull after 90 seconds. A pull that is still running keeps going in the background for up to 10 minutes, one at a time, and `hq doctor` shows that journal as "refresh in progress" until it finishes. It never blocks your own `hq sync`, and it steps aside at once for `hq rescue` and other repairs, keeping what it already downloaded. A large company is no longer reported as stale every day.
13
+
14
+ ## [5.345.168] — 2026-10-24
15
+ - `hq dm` now picks the default audience from the recipient when no audience flag is passed. A 1:1 DM to a person (email, `prs_` uid, or a bare name that resolves to a person) sends `audience: "human"`. A 1:1 DM to an agent (`agt_` uid), a group DM and a channel post send `audience: "agent"`, so script and lane messages stay out of people's conversations. Scheduled sends (`--at`, `--in`) follow the same rule. Pass `--for-human` (alias for `--audience human`) to tag any message for people; an explicit `--audience human|agent|both` wins over `--for-human`, with a warning on stderr, and is validated on every path. `hq core delegate send` tags the pickup DM `audience: "both"`, `hq dm-bind post` keeps the channel default (`audience: "agent"`) for its automated status posts, and the agent MCP `hq_dm_send` tool, `hq agents message` and the `hq access` grant request apply the recipient rule. Reading commands (inbox, thread, channel) are unchanged, and `hq dm forward` does not set an audience.
16
+ - `hq scan secrets` no longer exits 2 when a repo's `.gitleaks.toml` allowlist is rejected. In staged and file scans it now prints a fixed warning that quotes no part of the file and scans with no repo allowlist, so a real key is still reported (exit 1) and a commit that replaces a bad allowlist is not blocked. An unknown `--allowlist-rev` still exits 2.
17
+
5
18
  ## [5.345.166] — 2026-10-24
6
19
  - `hq integrations approve` now says why an approved call did not run. When an app refuses it, the CLI prints the app's own reason, such as Slack's refusal to send into a Slack Connect channel. Before this it printed only "failed (rejected)". With an older server it prints the rejection code instead. `hq integrations pending --history` adds a `Why:` line for decided calls that did not run. The approve command printed after a queued call keeps `--company` when you passed it. Before printing, the reason is cleaned of terminal escape codes, credentials and credential-bearing links, and it is capped at 500 characters. Feedback 15e7593c-50e5-4e6a-ae3a-342d618363c0.
7
20
  - The bundled sync engine now uses hq-cloud 6.18.101, the current npm `latest`.
@@ -283,7 +283,7 @@ if ! jq '.status = "sending"' "$MANIFEST" > "$TMP_MANIFEST" || ! mv "$TMP_MANIFE
283
283
  rm -f "$TMP_MANIFEST"
284
284
  die "could not persist sending state; no DM attempted"
285
285
  fi
286
- DM_OUT="$(hq dm "$PRINCIPAL" "$HEADLINE" --prompt-file "$PROMPT" --details-file "$BRIEF")" \
286
+ DM_OUT="$(hq dm "$PRINCIPAL" "$HEADLINE" --audience both --prompt-file "$PROMPT" --details-file "$BRIEF")" \
287
287
  || die "hq dm delivery outcome is uncertain; status is sending, inspect DM history before retrying"
288
288
 
289
289
  EVENT_ID="$(printf '%s' "$DM_OUT" | grep -oE 'eventId[: ]+[A-Za-z0-9_-]+' | head -1 | sed -E 's/eventId[: ]+//' || true)"
@@ -3987,7 +3987,7 @@ export declare const COMMAND_CATALOG: readonly [{
3987
3987
  }];
3988
3988
  }, {
3989
3989
  readonly name: "dm";
3990
- readonly description: "Send and read direct messages, and manage connection requests.";
3990
+ readonly description: "Send and read direct messages, and manage connection requests. Messages to a person send to them; messages to an agent, a channel or a group are tagged for agents and folded away from people by default. Pass --for-human or --audience human|agent|both to override.";
3991
3991
  readonly aliases: readonly [];
3992
3992
  readonly hidden: false;
3993
3993
  readonly helpOption: true;
@@ -4054,7 +4054,11 @@ export declare const COMMAND_CATALOG: readonly [{
4054
4054
  readonly hidden: false;
4055
4055
  }, {
4056
4056
  readonly flags: "--audience <human|agent|both>";
4057
- readonly description: "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both";
4057
+ readonly description: "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both. Default: human for a person, agent for an agent, a channel or a group. Wins over --for-human.";
4058
+ readonly hidden: false;
4059
+ }, {
4060
+ readonly flags: "--for-human";
4061
+ readonly description: "Tag the message for people (alias for --audience human).";
4058
4062
  readonly hidden: false;
4059
4063
  }, {
4060
4064
  readonly flags: "--attach <path>";
@@ -4827,7 +4827,7 @@ export const COMMAND_CATALOG = [
4827
4827
  },
4828
4828
  {
4829
4829
  "name": "dm",
4830
- "description": "Send and read direct messages, and manage connection requests.",
4830
+ "description": "Send and read direct messages, and manage connection requests. Messages to a person send to them; messages to an agent, a channel or a group are tagged for agents and folded away from people by default. Pass --for-human or --audience human|agent|both to override.",
4831
4831
  "aliases": [],
4832
4832
  "hidden": false,
4833
4833
  "helpOption": true,
@@ -4908,7 +4908,12 @@ export const COMMAND_CATALOG = [
4908
4908
  },
4909
4909
  {
4910
4910
  "flags": "--audience <human|agent|both>",
4911
- "description": "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both",
4911
+ "description": "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both. Default: human for a person, agent for an agent, a channel or a group. Wins over --for-human.",
4912
+ "hidden": false
4913
+ },
4914
+ {
4915
+ "flags": "--for-human",
4916
+ "description": "Tag the message for people (alias for --audience human).",
4912
4917
  "hidden": false
4913
4918
  },
4914
4919
  {
@@ -25,6 +25,7 @@ import { listJournals, syncDoctor, } from "@indigoai-us/hq-cloud";
25
25
  import { listActiveMembers } from "./members.js";
26
26
  import { aclLockedBy } from "./acl-lock.js";
27
27
  import { buildDmBody } from "./dm.js";
28
+ import { defaultAudienceFor, recipientKindOf } from "../lib/notify-recipient.js";
28
29
  import { peekIdToken } from "../utils/id-token.js";
29
30
  import { findRecentAccessRequest, formatTimeAgo, recordAccessRequest, } from "../utils/access-requests.js";
30
31
  import { recordAccessOutcome } from "../utils/access-outcomes.js";
@@ -1155,6 +1156,7 @@ async function buildLiveAccessDeps(slug, hqRoot) {
1155
1156
  prompt: msg.prompt,
1156
1157
  details: msg.details,
1157
1158
  now: Date.now(),
1159
+ audience: defaultAudienceFor(recipientKindOf(msg.recipient)),
1158
1160
  });
1159
1161
  const res = await vaultApiFetch({
1160
1162
  token,
@@ -42,7 +42,7 @@ import { consoleOrigin } from "./integrations-connect.js";
42
42
  import { launchSessionManagerPlugin, SessionManagerPluginLaunchError, TerminalSessionTimeoutError, } from "@indigoai-us/hq-cloud/outposts/node";
43
43
  import { peekIdToken } from "../utils/id-token.js";
44
44
  import { canOfferTeamUpgrade, formatUpgradeRefusal, isPlanGateError, offerUpgradeLink, } from "../utils/plan-gate-error.js";
45
- import { AGENT_UID_PATTERN, isAgentUid } from "../lib/notify-recipient.js";
45
+ import { AGENT_UID_PATTERN, defaultAudienceFor, isAgentUid } from "../lib/notify-recipient.js";
46
46
  import { AGENT_MODEL_PROVIDERS, modelSelectionErrorMessage, readAgentModelCatalog, validateAgentModelSelection, } from "../lib/agents/model-catalog.js";
47
47
  import { fetchCompanyPricingPlan, isUsageOnly, usageOnlyBoxSentence, usageOnlyCardRequiredLines, } from "../lib/billing/usage-only.js";
48
48
  import { BUNDLED_USAGE_CARD, fetchUsageCard, } from "../lib/billing/usage-card.js";
@@ -1139,7 +1139,7 @@ export async function sendAgentDm(token, agentUid, message) {
1139
1139
  token,
1140
1140
  path: "/v1/notify/dm",
1141
1141
  method: "POST",
1142
- body: { toPersonUid: agentUid, body: message },
1142
+ body: { toPersonUid: agentUid, body: message, audience: defaultAudienceFor("agent") },
1143
1143
  });
1144
1144
  }
1145
1145
  /** Read the two-way DM conversation with an agent (most recent `limit`). */
@@ -1206,8 +1206,30 @@ function appendPending(pendingPath, payload) {
1206
1206
  // runs once the queue is already over cap, where newest-wins is the point.
1207
1207
  const kept = lines.slice(-MAX_PENDING_PAYLOADS);
1208
1208
  fs.writeFileSync(pendingPath, `${kept.join("\n")}\n`);
1209
+ // Say so instead of dropping silently: a wedged sibling shows up here.
1210
+ const dropped = lines.length - kept.length;
1211
+ printError(`checkpoint: pending queue over cap (${MAX_PENDING_PAYLOADS}); dropped ${dropped} oldest payload(s)`);
1209
1212
  return kept.length;
1210
1213
  }
1214
+ /**
1215
+ * Queue a payload for the next sibling run and record its ontology touches.
1216
+ * Shared by the busy-sibling path and the debounced automatic path, so both
1217
+ * hand their thread to the next run instead of leaving it unfilled.
1218
+ */
1219
+ async function queuePayload(liveRoot, payload) {
1220
+ const siblingRoot = path.join(liveRoot, "workspace", "checkpoints", "sibling");
1221
+ fs.mkdirSync(siblingRoot, { recursive: true });
1222
+ const depth = appendPending(path.join(siblingRoot, "pending.jsonl"), payload);
1223
+ try {
1224
+ const { recordQueuedTouches } = await import("../lib/checkpoint-ontology/queued-touches.js");
1225
+ recordQueuedTouches(liveRoot, payload.company, payload.files, new Date());
1226
+ }
1227
+ catch {
1228
+ // Queued touches only help a later ontology plan; they must never fail
1229
+ // the checkpoint itself.
1230
+ }
1231
+ return depth;
1232
+ }
1211
1233
  /**
1212
1234
  * Take ownership of the queued payloads by renaming the queue aside.
1213
1235
  *
@@ -1369,15 +1391,7 @@ async function startSibling(liveRoot, input, threadPath, backend) {
1369
1391
  const activePid = existingSiblingPid(lockPath, Date.now());
1370
1392
  fs.mkdirSync(siblingRoot, { recursive: true });
1371
1393
  if (activePid !== null) {
1372
- const depth = appendPending(pendingPath, payload);
1373
- try {
1374
- const { recordQueuedTouches } = await import("../lib/checkpoint-ontology/queued-touches.js");
1375
- recordQueuedTouches(liveRoot, payload.company, payload.files, new Date());
1376
- }
1377
- catch {
1378
- // Queued touches only help a later ontology plan; they must never fail
1379
- // the checkpoint itself.
1380
- }
1394
+ const depth = await queuePayload(liveRoot, payload);
1381
1395
  printResult(`checkpoint: sibling busy — payload queued (${depth} pending)`);
1382
1396
  return;
1383
1397
  }
@@ -1579,7 +1593,13 @@ async function runCheckpoint(options, command, group, preResolvedHqRoot) {
1579
1593
  printResult(`checkpoint: sibling disabled (${SIBLING_SETTING})`);
1580
1594
  }
1581
1595
  else if (autoDebounced) {
1582
- printResult("checkpoint: sibling skipped (automatic checkpoint; one ran for this session recently)");
1596
+ // feedback_a2a5c2c3: the debounce limits how many siblings start, but
1597
+ // the thread still needs filling. Queue it once; the next sibling run
1598
+ // claims it. The debounce stamp is not refreshed, so the window still
1599
+ // closes on schedule and the next automatic checkpoint starts a run.
1600
+ const depth = await queuePayload(liveRoot, siblingPayload(input, threadPath, "none"));
1601
+ printResult("checkpoint: sibling skipped (automatic checkpoint; one ran for this session recently)" +
1602
+ ` — payload queued (${depth} pending)`);
1583
1603
  }
1584
1604
  else if (backend === "none") {
1585
1605
  printResult("checkpoint: sibling disabled (backend none)");
@@ -1079,7 +1079,8 @@ function runSend(args, context) {
1079
1079
  catch {
1080
1080
  return fail("send", "could not persist sending state; no DM attempted");
1081
1081
  }
1082
- const dm = runHq(["dm", principal, headline, "--prompt-file", prompt, "--details-file", brief], context);
1082
+ // The pickup DM is read by the person and acted on by their agent.
1083
+ const dm = runHq(["dm", principal, headline, "--audience", "both", "--prompt-file", prompt, "--details-file", brief], context);
1083
1084
  if (dm.code !== 0)
1084
1085
  return fail("send", "hq dm delivery outcome is uncertain; status is sending, inspect DM history before retrying");
1085
1086
  const event = /eventId[: ]+([A-Za-z0-9_-]+)/.exec(dm.stdout ?? "")?.[1] ?? "";
@@ -579,6 +579,8 @@ async function post(d, args) {
579
579
  const versionError = requireCliVersion(d);
580
580
  if (versionError)
581
581
  return versionError;
582
+ // An automated status post keeps the channel default (agent audience); only a
583
+ // reply to a message a person wrote is for humans.
582
584
  const call = ["dm", ch, ...(root ? ["--thread", root] : []), cliMessage];
583
585
  const sent = d.runHq(call, { ...d.env, HQ_ROOT: d.root });
584
586
  if (sent.code !== 0)
@@ -594,7 +596,7 @@ async function post(d, args) {
594
596
  const mentions = mentionsJson(d, sid, names);
595
597
  if (!mentions.length)
596
598
  return fail("no mention resolved to a channel member id — not posting an update that notifies nobody");
597
- const payload = { body: postedMessage, mentions };
599
+ const payload = { body: postedMessage, mentions, audience: "agent" };
598
600
  if (root)
599
601
  payload.rootEventId = root;
600
602
  try {
@@ -1,4 +1,6 @@
1
1
  import { Command } from "commander";
2
+ import { defaultAudienceFor, recipientKindOf, type DmRecipientKind } from "../lib/notify-recipient.js";
3
+ export { defaultAudienceFor, recipientKindOf, type DmRecipientKind };
2
4
  import { type DmAttachmentWire } from "./dm-attach.js";
3
5
  export interface DmRecipient {
4
6
  toEmail?: string;
@@ -158,7 +160,8 @@ export declare function resolveMentions(tokens: string[], members: ChannelMember
158
160
  * Returns null on anything that doesn't match. Pure → unit-testable.
159
161
  */
160
162
  export declare function parseDuration(input: string): number | null;
161
- type DmAudience = "human" | "agent" | "both";
163
+ import type { DmAudience } from "../lib/notify-recipient.js";
164
+ export type { DmAudience };
162
165
  export interface DmSendBody {
163
166
  toEmail?: string;
164
167
  toPersonUid?: string;
@@ -233,6 +236,15 @@ export declare function fetchChannels(token: string): Promise<ChannelSummary[]>;
233
236
  * post itself passes. Used to resolve `@name` mentions into participant uids.
234
237
  */
235
238
  export declare function fetchChannelMembers(token: string, channelId: string): Promise<ChannelMember[]>;
239
+ /**
240
+ * Resolve the audience for a send. The default comes from the recipient kind
241
+ * (`defaultAudienceFor`); `--for-human` maps to `human`; an explicit
242
+ * `--audience` wins over `--for-human` (with a warning) and is validated.
243
+ */
244
+ export declare function resolveSendAudience(opts: {
245
+ audience?: string;
246
+ forHuman?: boolean;
247
+ }, recipientKind: DmRecipientKind, warn?: (msg: string) => void): string;
236
248
  /** One incoming DM as returned by GET /v1/notify/inbox. */
237
249
  export interface DmInboxEvent {
238
250
  eventId: string;
@@ -411,5 +423,4 @@ export declare function formatChannelTranscript(messages: ChannelMessageItem[],
411
423
  export declare function agentCallerUid(token: string, machine?: () => boolean): string | null;
412
424
  export declare const AGENT_THREAD_UNAVAILABLE: string;
413
425
  export declare function registerDmCommand(program: Command): void;
414
- export {};
415
426
  //# sourceMappingURL=dm.d.ts.map
@@ -5,7 +5,8 @@ import { resolveDestinationCompanyUid, vaultApiFetch } from "../utils/vault-api.
5
5
  import { isMachineIdentity } from "@indigoai-us/hq-cloud";
6
6
  import { peekIdToken } from "../utils/id-token.js";
7
7
  import { redactAgentInboxItem as redactInboxCredentials } from "../lib/agent-kit/inbox-redact.js";
8
- import { EMAIL_PATTERN, RECIPIENT_UID_PATTERN } from "../lib/notify-recipient.js";
8
+ import { EMAIL_PATTERN, RECIPIENT_UID_PATTERN, defaultAudienceFor, recipientKindOf, } from "../lib/notify-recipient.js";
9
+ export { defaultAudienceFor, recipientKindOf };
9
10
  import { dmForwardCliEnabled } from "./dm-forward-flag.js";
10
11
  import { attachmentScopeForChannel, formatUploadedAttachments, personalScopeForCaller, uploadDmAttachments, validateAttachmentPaths, } from "./dm-attach.js";
11
12
  import { ChannelNotFoundError, suggestChannels, withChannelSuggestions, } from "./channel-suggest.js";
@@ -555,7 +556,7 @@ async function runConnectionAction(action, identifier) {
555
556
  * the server's idempotent participant-key dedupe) a group channel, then posts
556
557
  * the message into it. Reuses the same vault API client + auth as the 1:1 path.
557
558
  */
558
- async function runGroupSend(recipients, message) {
559
+ async function runGroupSend(recipients, message, audience) {
559
560
  try {
560
561
  const participants = [];
561
562
  for (const r of recipients) {
@@ -601,7 +602,7 @@ async function runGroupSend(recipients, message) {
601
602
  token,
602
603
  path: `/v1/notify/channels/${encodeURIComponent(channelId)}/messages`,
603
604
  method: "POST",
604
- body: { body },
605
+ body: { body, ...(audience ? { audience } : {}) },
605
606
  });
606
607
  if (!sendRes.ok) {
607
608
  const err = (await sendRes.json().catch(() => ({})));
@@ -810,11 +811,41 @@ async function runChannelSend(channelName, message, opts) {
810
811
  process.exit(1);
811
812
  }
812
813
  }
813
- async function runDmSend(recipient, message, opts) {
814
+ function explicitAudience(opts) {
815
+ const explicit = opts.audience !== undefined && opts.audience !== "" ? opts.audience : undefined;
816
+ if (explicit !== undefined && !DM_AUDIENCE_VALUES.includes(explicit)) {
817
+ throw new Error("Invalid --audience '" + explicit + "': must be human, agent, or both.");
818
+ }
819
+ return explicit;
820
+ }
821
+ /**
822
+ * Resolve the audience for a send. The default comes from the recipient kind
823
+ * (`defaultAudienceFor`); `--for-human` maps to `human`; an explicit
824
+ * `--audience` wins over `--for-human` (with a warning) and is validated.
825
+ */
826
+ export function resolveSendAudience(opts, recipientKind, warn = (m) => console.error(chalk.yellow(m))) {
827
+ const explicit = explicitAudience(opts);
828
+ if (explicit !== undefined) {
829
+ if (opts.forHuman) {
830
+ warn("Both --audience " + explicit + " and --for-human were passed; using --audience " + explicit + ".");
831
+ }
832
+ return explicit;
833
+ }
834
+ return opts.forHuman ? "human" : defaultAudienceFor(recipientKind);
835
+ }
836
+ async function runDmSend(recipient, message, rawOpts) {
837
+ // Validate an explicit --audience before any network call; the default is
838
+ // applied once the recipient kind is known.
839
+ explicitAudience(rawOpts);
840
+ const opts = { ...rawOpts };
841
+ const withAudience = (kind) => ({
842
+ ...rawOpts,
843
+ audience: resolveSendAudience(rawOpts, kind),
844
+ });
814
845
  // --channel <name> is an explicit channel target; the positional carries the
815
846
  // message (recipient slot), e.g. `hq dm --channel vyg-dev "hello"`.
816
847
  if (opts.channel !== undefined) {
817
- await runChannelSend(opts.channel, message ?? recipient, opts);
848
+ await runChannelSend(opts.channel, message ?? recipient, withAudience("channel"));
818
849
  return;
819
850
  }
820
851
  if (recipient === undefined) {
@@ -837,7 +868,7 @@ async function runDmSend(recipient, message, opts) {
837
868
  process.exit(1);
838
869
  return;
839
870
  }
840
- await runGroupSend(group, message);
871
+ await runGroupSend(group, message, withAudience("group").audience);
841
872
  return;
842
873
  }
843
874
  // A bare name or #hash addresses a named DM channel.
@@ -849,12 +880,13 @@ async function runDmSend(recipient, message, opts) {
849
880
  ? null
850
881
  : matchContactByName(await fetchContacts(await ensureCognitoToken()), channelName);
851
882
  if (!person?.personUid) {
852
- await runChannelSend(channelName, message, opts);
883
+ await runChannelSend(channelName, message, withAudience("channel"));
853
884
  return;
854
885
  }
855
886
  console.log(chalk.dim(`Resolved '${channelName}' to person ${person.displayName || person.email || person.personUid} (${person.personUid}). Use '#${channelName}' or --channel ${channelName} to post to a channel instead.`));
856
887
  recipient = person.personUid;
857
888
  }
889
+ Object.assign(opts, withAudience(recipientKindOf(recipient)));
858
890
  if (opts.thread !== undefined) {
859
891
  console.error(chalk.red("--thread is only supported for channel messages (hq dm <channel> --thread <eventId|last> \"…\")."));
860
892
  process.exit(1);
@@ -1697,7 +1729,7 @@ async function runDmChannel(target, opts) {
1697
1729
  export function registerDmCommand(program) {
1698
1730
  const dm = program
1699
1731
  .command("dm")
1700
- .description("Send and read direct messages, and manage connection requests.");
1732
+ .description("Send and read direct messages, and manage connection requests. Messages to a person send to them; messages to an agent, a channel or a group are tagged for agents and folded away from people by default. Pass --for-human or --audience human|agent|both to override.");
1701
1733
  dm
1702
1734
  .command("send [recipient] [message]", { isDefault: true, hidden: true })
1703
1735
  .description('Send a direct message. RECIPIENT can be a person (email, personUid, or agentUid), a GROUP DM (comma-separated: "a@x.com,b@y.com"), or one of your DM CHANNELS by name — bare (hq dm vyg-dev "hi"), hash form (hq dm "#vyg-dev" "hi"), or via --channel (hq dm --channel vyg-dev "hi"). A person receives a DM as an HQ Sync notification; an agent receives it in its durable box inbox. If you aren\'t connected yet, it sends a connection request that holds your message. See your channels with `hq channels`.\n\nCHANNEL MENTIONS: HQ mentions are structured — the server never reads `@name` out of your text, so a channel message has to carry the participants alongside the body. When you write `@name` in a channel message, this command looks the name up in the channel roster (case-insensitive on display name and slug/handle; quote multi-word names as @"Jacob Posel") and sends it as a real mention, which notifies people and wakes agents. If a name is unknown or matches more than one member, the send FAILS with the candidates instead of posting something that mentions nobody. A bare "@" or an email address in your prose is never treated as a mention. Pass --no-mentions to post the text literally.\n\nCHANNEL THREADS: `hq dm <channel> --thread <eventId> "…"` replies under an existing root (the request carries rootEventId). `--thread last` replies to your most recent top-level message in that channel. Copy a root eventId from `hq dm channel <name>`, which prints each root\'s id and indents replies underneath.')
@@ -1710,7 +1742,8 @@ export function registerDmCommand(program) {
1710
1742
  .option("--details-file <path>", "Read the details from a file (1:1 DMs; channel sends also supported)")
1711
1743
  .option("--at <iso>", "Schedule delivery at an ISO8601 time (store-and-forward; delivered within ~60s of the time; 1:1 DMs only)")
1712
1744
  .option("--in <duration>", "Schedule delivery after a relative delay: 30s, 10m, 2h, 1d (1:1 DMs only)")
1713
- .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")
1745
+ .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. Default: human for a person, agent for an agent, a channel or a group. Wins over --for-human.")
1746
+ .option("--for-human", "Tag the message for people (alias for --audience human).")
1714
1747
  .option("--attach <path>", "Upload a local file and attach it to the message (repeatable, up to 5 files, 25 MB each). 1:1 DMs and named channels; the file lands in the conversation's vault scope and the recipient can open it. Not available for group DMs or scheduled sends.", (value, previous = []) => [...previous, value])
1715
1748
  .action(async (recipient, message, opts) => {
1716
1749
  await runDmSend(recipient, message, opts);
@@ -3,6 +3,7 @@ import chalk from "chalk";
3
3
  import { ensureCognitoToken, loadCachedTokens } from "../utils/cognito-session.js";
4
4
  import { assertVaultApiRequestSupported, vaultApiFetch, } from "../utils/vault-api.js";
5
5
  import { collectDiagnostics } from "../utils/feedback-diagnostics.js";
6
+ import { scrubDiagnosticsString, scrubDiagnosticsValue } from "../utils/scrub-diagnostics.js";
6
7
  import { MAX_SCREENSHOTS, uploadScreenshots } from "../utils/feedback-screenshots.js";
7
8
  import { FEEDBACK_LOGS_MAX_TOTAL_BYTES, FEEDBACK_LOGS_MIN_USEFUL_BYTES, collectFeedbackLogs, feedbackLogsEnabled, } from "../utils/feedback-logs.js";
8
9
  import { uploadLogBundle } from "../utils/feedback-log-bundle.js";
@@ -79,8 +80,10 @@ export function resolveFeedbackReporter(env = process.env, isTTY = process.stdin
79
80
  function buildFeedbackRequestBody(opts) {
80
81
  const requestBody = {
81
82
  type: opts.type,
82
- title: opts.title,
83
- body: opts.body,
83
+ // Title and body are free text, and /hq-bug puts tool output in the body,
84
+ // so they get the same credential scrub as diagnostics.
85
+ title: scrubDiagnosticsString(opts.title),
86
+ body: scrubDiagnosticsString(opts.body),
84
87
  diagnostics: opts.diagnostics,
85
88
  };
86
89
  if (opts.reporterMark) {
@@ -157,7 +160,12 @@ export async function submitFeedback(opts) {
157
160
  // Keep submitFeedback safe for direct callers as well as the command path,
158
161
  // which invokes the same validator before uploading any attachments.
159
162
  validateFeedbackContent(opts.title, opts.body);
160
- const diagnostics = opts.diagnostics ?? collectDiagnostics();
163
+ // Scrub here, at the one point every submission passes through. Callers may
164
+ // hand in their own diagnostics (the command path collects them before
165
+ // uploads, and adds latestVersion afterwards), and a caller-supplied blob
166
+ // previously skipped redaction entirely. The scrubbed copy is the object
167
+ // attachDebugLogs later mutates, so inline logs still land in the request.
168
+ const diagnostics = scrubDiagnosticsValue(opts.diagnostics ?? collectDiagnostics());
161
169
  const reporterMark = opts.type === "bug" && resolveProcessFlagGate(FEEDBACK_REPORTER_KIND_FLAG, { fallback: FEEDBACK_REPORTER_KIND_FLAG_FALLBACK }, () => FEEDBACK_REPORTER_KIND_FLAG_FALLBACK)
162
170
  ? resolveFeedbackReporter(process.env, process.stdin.isTTY === true, opts.reporterOverride)
163
171
  : undefined;
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { CLI_VERSION } from "../../../cli-version.js";
19
19
  import { peekIdToken } from "../../../utils/id-token.js";
20
- import { isRecipientUid } from "../../../lib/notify-recipient.js";
20
+ import { defaultAudienceFor, isAgentUid, isRecipientUid } from "../../../lib/notify-recipient.js";
21
21
  import { redactAgentInboxItem } from "../inbox-redact.js";
22
22
  import { markInboxDone, mergeAgentInbox, readDoneIds, summarizeInboxEntry } from "../inbox-state.js";
23
23
  import { AttachmentCompanyUnresolvedError, fetchChatAttachment } from "./attachments.js";
@@ -214,13 +214,13 @@ export function buildAgentMcpTools(clients) {
214
214
  },
215
215
  {
216
216
  name: "hq_dm_send",
217
- description: "Send an HQ message as this agent to a person (email or prs_…), an agent (agt_…), or a channel or group DM (chn_…). When replying to an inbox item that has a channelId, pass that channelId as `to` so the answer lands in the conversation it came from. Use audience='agent' for progress/status messages that only agents need to see; audience='human' for messages a person must read; audience='both' when unsure.",
217
+ description: "Send an HQ message as this agent to a person (email or prs_…), an agent (agt_…), or a channel or group DM (chn_…). When replying to an inbox item that has a channelId, pass that channelId as `to` so the answer lands in the conversation it came from. Use audience='agent' for progress/status messages that only agents need to see; audience='human' for messages a person must read; audience='both' when unsure. Default: human for a person, agent for an agent, a channel or a group.",
218
218
  inputSchema: {
219
219
  type: "object",
220
220
  properties: {
221
221
  to: { type: "string", description: "Email, prs_… or agt_… uid, or a chn_… channel id" },
222
222
  message: { type: "string" },
223
- audience: { type: "string", enum: ["human", "agent", "both"], description: "Who this message is for (default: human)" },
223
+ audience: { type: "string", enum: ["human", "agent", "both"], description: "Who this message is for (default: human for a person, agent for an agent, channel or group)" },
224
224
  },
225
225
  required: ["to", "message"],
226
226
  additionalProperties: false,
@@ -238,7 +238,7 @@ export function buildAgentMcpTools(clients) {
238
238
  const token = await clients.getToken();
239
239
  const res = await clients.apiJson(token, `/v1/notify/channels/${encodeURIComponent(to)}/messages`, {
240
240
  method: "POST",
241
- body: { body: message, ...(audience ? { audience } : {}) },
241
+ body: { body: message, audience: audience ?? defaultAudienceFor("channel") },
242
242
  });
243
243
  if (res.status < 200 || res.status >= 300) {
244
244
  return errorResult(`Channel send failed (${res.status}): ${describeError(res.body)}`);
@@ -252,8 +252,7 @@ export function buildAgentMcpTools(clients) {
252
252
  : (() => {
253
253
  throw new McpToolInputError('"to" must be an email, prs_… or agt_… uid, or a chn_… channel id');
254
254
  })();
255
- if (audience)
256
- body.audience = audience;
255
+ body.audience = audience ?? defaultAudienceFor(isAgentUid(to) ? "agent" : "person");
257
256
  const token = await clients.getToken();
258
257
  const res = await clients.apiJson(token, "/v1/notify/dm", { method: "POST", body });
259
258
  if (res.status < 200 || res.status >= 300) {
@@ -116,6 +116,13 @@ export interface SyncHealthDeps {
116
116
  * since a re-upload would exit 0 without uploading.
117
117
  */
118
118
  manifestUploadsDisabled?: () => boolean;
119
+ /**
120
+ * Start time (epoch ms) of a live background `hq doctor --fix` pull for
121
+ * this refresh target (a company slug, or `personal`), or null. A journal
122
+ * whose refresh is still running is reported "refresh in progress" rather
123
+ * than stale (feedback cd56a501).
124
+ */
125
+ refreshInProgress?: (fixTarget: string) => number | null;
119
126
  }
120
127
  /**
121
128
  * True when `slug` is a filesystem-safe company folder name. Rejects empty
@@ -148,7 +155,6 @@ export declare function isPersonalJournalSlug(slug: string): boolean;
148
155
  export declare function journalSlugFromCheckId(checkId: string): string | null;
149
156
  /** The versions/sync check family. Registered in `createDefaultRegistry`. */
150
157
  export declare const syncHealthFamily: CheckFamily;
151
- /** Run every versions/sync check. A thrown check degrades to UNKNOWN. */
152
158
  export declare function checkSyncHealth(context: CheckContext, rawDeps?: SyncHealthDeps): CheckResult[];
153
159
  /** True when `a` > `b` for plain X.Y.Z versions. Non-numeric parts compare 0. */
154
160
  export declare function semverGt(a: string, b: string): boolean;
@@ -25,6 +25,8 @@
25
25
  */
26
26
  import { defaultSyncRuntimeResults } from "./sync-runtime.js";
27
27
  import * as fs from "node:fs";
28
+ import { DEFAULT_OWNER_IDENTITY } from "../fix/process-identity.js";
29
+ import { REFRESH_SPAWN_LOCK_WARN_MS, inspectRefreshSpawnLock, refreshHandoffStartedAt, } from "../fix/refresh-sync-handoff.js";
28
30
  import * as path from "node:path";
29
31
  // Namespace import, deliberately: hq-cloud is an ESM package, so a static
30
32
  // NAMED import of an export the installed version lacks is a link-time
@@ -63,6 +65,8 @@ function withDefaults(deps, hqRoot) {
63
65
  companySyncResolvable: deps.companySyncResolvable ??
64
66
  ((slug, lastSync) => !companyRecordedNotResolved(hqRoot, slug, deps.now().getTime(), lastSync)),
65
67
  manifestUploadsDisabled: deps.manifestUploadsDisabled ?? (() => manifestUploadsDisabled()),
68
+ refreshInProgress: deps.refreshInProgress ??
69
+ ((fixTarget) => refreshHandoffStartedAt(hqRoot, fixTarget, deps.now().getTime())),
66
70
  };
67
71
  }
68
72
  /**
@@ -294,6 +298,36 @@ export const syncHealthFamily = {
294
298
  run: (context) => Promise.resolve(checkSyncHealth(context)),
295
299
  };
296
300
  /** Run every versions/sync check. A thrown check degrades to UNKNOWN. */
301
+ /**
302
+ * WARN when the `hq doctor --fix` spawn lock has been held for over
303
+ * {@link REFRESH_SPAWN_LOCK_WARN_MS}, naming the holder pid, so a stalled or
304
+ * suspended holder is visible. Report only: the lock is never removed here.
305
+ */
306
+ function refreshSpawnLockResult(hqRoot, nowMs) {
307
+ let lock;
308
+ try {
309
+ lock = inspectRefreshSpawnLock(hqRoot, nowMs, DEFAULT_OWNER_IDENTITY);
310
+ }
311
+ catch {
312
+ return [];
313
+ }
314
+ if (!lock || lock.heldMs === null || lock.heldMs <= REFRESH_SPAWN_LOCK_WARN_MS)
315
+ return [];
316
+ // Left by a claimer that has exited: nothing to stop, and the next
317
+ // `hq doctor --fix` releases it.
318
+ if (lock.ownerGone)
319
+ return [];
320
+ const minutes = Math.floor(lock.heldMs / 60_000);
321
+ return [
322
+ {
323
+ status: "WARN",
324
+ checkId: "sync.refresh.spawn-lock",
325
+ reasonCode: "refresh-lock-held",
326
+ message: `The hq doctor --fix refresh lock has been held for ${minutes} minutes by process ${lock.pid}. No new journal refresh can start until it is released.`,
327
+ remediation: `If process ${lock.pid} is stuck or suspended, resume or stop it; the lock is released when it exits.`,
328
+ },
329
+ ];
330
+ }
297
331
  export function checkSyncHealth(context, rawDeps = DEFAULT_DEPS) {
298
332
  const deps = withDefaults(rawDeps, context.hqRoot);
299
333
  try {
@@ -301,6 +335,7 @@ export function checkSyncHealth(context, rawDeps = DEFAULT_DEPS) {
301
335
  ...versionResults(context, deps),
302
336
  ...updateAvailabilityResult(context, deps),
303
337
  ...journalAndManifestResults(deps),
338
+ ...refreshSpawnLockResult(context.hqRoot, deps.now().getTime()),
304
339
  ...deps.runtime(deps.now()),
305
340
  ];
306
341
  }
@@ -528,7 +563,7 @@ function journalResults(deps, journals) {
528
563
  if (entry.slug === PERSONAL_SCOPE_SLUG) {
529
564
  results.push(leftoverPersonalJournalResult(entry));
530
565
  if (!hasPersonalVaultJournal) {
531
- results.push(journalFreshnessResult(entry, PERSONAL_VAULT_SCOPE_SLUG, nowMs));
566
+ results.push(withRefreshInProgress(deps, journalFreshnessResult(entry, PERSONAL_VAULT_SCOPE_SLUG, nowMs), "personal"));
532
567
  }
533
568
  continue;
534
569
  }
@@ -570,10 +605,40 @@ function journalResults(deps, journals) {
570
605
  results.push(notOnThisMachineResult(entry, checkId));
571
606
  continue;
572
607
  }
573
- results.push(freshness);
608
+ results.push(withRefreshInProgress(deps, freshness, isPersonalJournalSlug(entry.slug) ? "personal" : entry.slug));
574
609
  }
575
610
  return results;
576
611
  }
612
+ /**
613
+ * A stale or never-synced journal whose `hq doctor --fix` pull is still
614
+ * running in the background is UNTESTED "refresh in progress", not WARN.
615
+ * The pull records lastSync only when it finishes, so until then the journal
616
+ * looks stale even though the repair is under way. Once the process is gone
617
+ * the record no longer counts and the WARN comes back, so a pull that died is
618
+ * retried by the next `--fix`.
619
+ */
620
+ function withRefreshInProgress(deps, result, fixTarget) {
621
+ if (result.status !== "WARN")
622
+ return result;
623
+ if (result.reasonCode !== "stale-threshold" && result.reasonCode !== "never-synced")
624
+ return result;
625
+ let startedAtMs;
626
+ try {
627
+ startedAtMs = deps.refreshInProgress(fixTarget);
628
+ }
629
+ catch {
630
+ return result;
631
+ }
632
+ if (startedAtMs === null)
633
+ return result;
634
+ return {
635
+ status: "UNTESTED",
636
+ checkId: result.checkId,
637
+ reasonCode: "refresh-in-progress",
638
+ ...(result.target ? { target: result.target } : {}),
639
+ message: `${result.message} hq doctor --fix is refreshing it in the background (started ${new Date(startedAtMs).toISOString()}); it passes when that pull finishes.`,
640
+ };
641
+ }
577
642
  function companySyncResolvable(deps, slug, lastSync) {
578
643
  try {
579
644
  return deps.companySyncResolvable(slug, lastSync);