@indigoai-us/hq-cli 5.122.2 → 5.123.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.123.0] — 2026-09-19
6
+
7
+ ### Fixed
8
+
9
+ - `hq mesh check --project` lists that project's open threads. The CLI queries
10
+ `/v1/work-mesh/threads` with `projectId` (and `status=open`) and merges the
11
+ result with the status listing, because the status index can omit project
12
+ threads.
13
+
14
+ - First signed-in interactive run prints a one-line telemetry disclosure on
15
+ stderr (once per machine). `HQ_TELEMETRY=0` (also `false` or `off`) disables
16
+ CLI telemetry posts. After the notice has been shown, events send
17
+ `consentBasis: "disclosed"` instead of `"no-consent"`.
18
+
19
+ ### Added
20
+
21
+ - Channel messages can now be posted as thread replies: `hq dm <channel>
22
+ --thread <eventId> "…"` sends `rootEventId` on the POST, and `--thread last`
23
+ replies to your most recent top-level message in that channel. `hq dm channel
24
+ <name>` prints each root's eventId and indents replies under their root
25
+ (`--json` is still the ungrouped oldest-first array). `@name` mentions were
26
+ already resolved against the roster into the structured `mentions` array;
27
+ unknown names still fail the send and list the roster (`--no-mentions` posts
28
+ the text literally).
29
+
30
+ ### Changed
31
+
32
+ - `hq whoami --company` and the bind notice no longer read `planLock`. They
33
+ render the Starter over-limit nag from `GET /v1/billing/usage-limits`
34
+ (including the top-level `freeMonth` CTA). Customer copy does not say
35
+ "locked" or "read-only".
36
+
37
+ ### Fixed
38
+
39
+ - Work Mesh board snapshots now read live story status from ProjectView before
40
+ falling back to the vault board, and `hq mesh daemon doctor` warns when a
41
+ presence credential omitted companies.
42
+
5
43
  ## [5.122.2] — 2026-09-19
6
44
 
7
45
  ### Fixed
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # hq-cli
2
+
3
+ HQ management CLI — modules, packages, and cloud sync.
4
+
5
+ ```bash
6
+ npm install -g @indigoai-us/hq-cli
7
+ hq --help
8
+ ```
9
+
10
+ ## Telemetry
11
+
12
+ When you are signed in, `hq` may POST one `cli_session_started` event to
13
+ `/v1/telemetry/events`. The event body is:
14
+
15
+ - `eventName`: `cli_session_started`
16
+ - `app`: `hq-cli`
17
+ - `source`: `cli`
18
+ - `occurredAt`: ISO timestamp
19
+ - `consentBasis`: `disclosed` after the first-run notice, otherwise `no-consent`
20
+ - `schemaVersion`: `1`
21
+ - `properties.version`: the CLI version
22
+
23
+ The request is authenticated with your cached session token so the control
24
+ plane can accept the event. The body does not include command names, arguments,
25
+ timings, file contents, or secrets.
26
+
27
+ The first signed-in interactive run (stdout is a TTY, and the command is not
28
+ `--json`) prints this line once on stderr and records that it was shown under
29
+ `~/.hq/cli-telemetry-disclosed`:
30
+
31
+ ```
32
+ HQ sends a usage event when you run a signed-in command (CLI version and a timestamp, never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.
33
+ ```
34
+
35
+ Turn telemetry off:
36
+
37
+ ```bash
38
+ export HQ_TELEMETRY=0
39
+ ```
40
+
41
+ `false` and `off` are accepted as well (case-insensitive). That disables every
42
+ CLI POST to `/v1/telemetry/events`. `HQ_TELEMETRY_TIMEOUT_MS` is a separate
43
+ timeout knob and is unchanged.
44
+
45
+ The same kill switch and event shape should be documented on hqforwork.com
46
+ when that site's telemetry page is added (indigo-marketing; not this repo).
@@ -833,7 +833,7 @@ export declare const COMMAND_CATALOG: readonly [{
833
833
  readonly description: "Output identity metadata as JSON (never tokens)";
834
834
  }, {
835
835
  readonly flags: "--company <slug>";
836
- readonly description: "Also report this company's workspace plan state (Starter lock)";
836
+ readonly description: "Also report this company's workspace plan state (Starter nag)";
837
837
  }];
838
838
  readonly subcommands: readonly [];
839
839
  }, {
@@ -2209,7 +2209,7 @@ export declare const COMMAND_CATALOG: readonly [{
2209
2209
  readonly options: readonly [];
2210
2210
  readonly subcommands: readonly [{
2211
2211
  readonly name: "send";
2212
- readonly 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.";
2212
+ readonly 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.";
2213
2213
  readonly aliases: readonly [];
2214
2214
  readonly hidden: true;
2215
2215
  readonly usage: "[options] [recipient] [message]";
@@ -2228,6 +2228,9 @@ export declare const COMMAND_CATALOG: readonly [{
2228
2228
  }, {
2229
2229
  readonly flags: "--no-mentions";
2230
2230
  readonly description: "Do not resolve `@name` in a channel message into structured mentions — post the text literally";
2231
+ }, {
2232
+ readonly flags: "--thread <eventId>";
2233
+ readonly description: "Reply in a channel thread. Pass a root eventId from `hq dm channel`, or `last` for your most recent top-level message in that channel";
2231
2234
  }, {
2232
2235
  readonly flags: "--prompt <text>";
2233
2236
  readonly description: "Agent-context prompt the recipient can one-click copy into their agent (1:1 DMs only)";
@@ -2293,7 +2296,7 @@ export declare const COMMAND_CATALOG: readonly [{
2293
2296
  readonly subcommands: readonly [];
2294
2297
  }, {
2295
2298
  readonly name: "channel";
2296
- readonly description: "Show recent messages in a DM channel or group DM — by name, #name, or a channel id from `hq channels`.";
2299
+ readonly description: "Show recent messages in a DM channel or group DM — by name, #name, or a channel id from `hq channels`. Roots print their eventId; replies indent under their root. --json is the raw oldest-first array (ungrouped).";
2297
2300
  readonly aliases: readonly ["history"];
2298
2301
  readonly hidden: false;
2299
2302
  readonly usage: "[options] <target>";
@@ -1064,7 +1064,7 @@ export const COMMAND_CATALOG = [
1064
1064
  },
1065
1065
  {
1066
1066
  "flags": "--company <slug>",
1067
- "description": "Also report this company's workspace plan state (Starter lock)"
1067
+ "description": "Also report this company's workspace plan state (Starter nag)"
1068
1068
  }
1069
1069
  ],
1070
1070
  "subcommands": []
@@ -2836,7 +2836,7 @@ export const COMMAND_CATALOG = [
2836
2836
  "subcommands": [
2837
2837
  {
2838
2838
  "name": "send",
2839
- "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.",
2839
+ "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.",
2840
2840
  "aliases": [],
2841
2841
  "hidden": true,
2842
2842
  "usage": "[options] [recipient] [message]",
@@ -2861,6 +2861,10 @@ export const COMMAND_CATALOG = [
2861
2861
  "flags": "--no-mentions",
2862
2862
  "description": "Do not resolve `@name` in a channel message into structured mentions — post the text literally"
2863
2863
  },
2864
+ {
2865
+ "flags": "--thread <eventId>",
2866
+ "description": "Reply in a channel thread. Pass a root eventId from `hq dm channel`, or `last` for your most recent top-level message in that channel"
2867
+ },
2864
2868
  {
2865
2869
  "flags": "--prompt <text>",
2866
2870
  "description": "Agent-context prompt the recipient can one-click copy into their agent (1:1 DMs only)"
@@ -2948,7 +2952,7 @@ export const COMMAND_CATALOG = [
2948
2952
  },
2949
2953
  {
2950
2954
  "name": "channel",
2951
- "description": "Show recent messages in a DM channel or group DM — by name, #name, or a channel id from `hq channels`.",
2955
+ "description": "Show recent messages in a DM channel or group DM — by name, #name, or a channel id from `hq channels`. Roots print their eventId; replies indent under their root. --json is the raw oldest-first array (ungrouped).",
2952
2956
  "aliases": [
2953
2957
  "history"
2954
2958
  ],
@@ -228,6 +228,8 @@ export interface ChannelMessageItem {
228
228
  fromDisplayName?: string;
229
229
  body: string;
230
230
  createdAt: string;
231
+ /** Present on a reply; absent on a top-level (root) message. */
232
+ rootEventId?: string;
231
233
  attachments?: DmAttachment[];
232
234
  attachment?: DmAttachment;
233
235
  }
@@ -296,7 +298,58 @@ export declare function formatInboxEvent(e: DmInboxEvent, nowMs: number): string
296
298
  /** Render one 1:1 thread line, tagged by direction. */
297
299
  export declare function formatThreadMessage(m: DmThreadMessage, nowMs: number): string;
298
300
  /** Render one channel/group message line. */
299
- export declare function formatChannelMessage(m: ChannelMessageItem, nowMs: number): string;
301
+ export declare function formatChannelMessage(m: ChannelMessageItem, nowMs: number, opts?: {
302
+ indent?: string;
303
+ showEventId?: boolean;
304
+ }): string;
305
+ /** Identity we can match against a channel message's from* fields. Pure. */
306
+ export interface CallerIdentity {
307
+ personUid?: string;
308
+ email?: string;
309
+ }
310
+ /**
311
+ * Read the caller's person/agent uid and email from a JWT we already hold.
312
+ * Used to resolve `--thread last` (most recent top-level message *by us*).
313
+ * Pure → unit-testable.
314
+ */
315
+ export declare function callerFromToken(token: string): CallerIdentity;
316
+ export declare function isCallerMessage(m: ChannelMessageItem, me: CallerIdentity): boolean;
317
+ /**
318
+ * Newest-first channel history → the caller's most recent top-level eventId.
319
+ * Replies (those with rootEventId) are skipped. Pure → unit-testable.
320
+ */
321
+ export declare function lastCallerRootEventId(messagesNewestFirst: ChannelMessageItem[], me: CallerIdentity): string | undefined;
322
+ export type ThreadFlagResolution = {
323
+ ok: true;
324
+ rootEventId?: string;
325
+ } | {
326
+ ok: false;
327
+ error: string;
328
+ };
329
+ /**
330
+ * Resolve `--thread <eventId|last>` against a newest-first message page.
331
+ * An explicit eventId is passed through; `last` picks our most recent
332
+ * top-level message. Pure → unit-testable (the fetch is the caller's job).
333
+ */
334
+ export declare function resolveThreadFlag(thread: string | undefined, messagesNewestFirst: ChannelMessageItem[], me: CallerIdentity): ThreadFlagResolution;
335
+ export interface ChannelThread {
336
+ root: ChannelMessageItem;
337
+ replies: ChannelMessageItem[];
338
+ /** True when the root is not in this page — replies hang off a placeholder. */
339
+ missingRoot?: boolean;
340
+ }
341
+ /**
342
+ * Group a channel history page into roots with their replies underneath.
343
+ * Orphan replies (root not in this page) hang off a placeholder carrying the
344
+ * missing root's eventId. Pure → unit-testable.
345
+ */
346
+ export declare function groupChannelMessages(messages: ChannelMessageItem[]): ChannelThread[];
347
+ /**
348
+ * Human channel history: each root prints its eventId so `--thread` can copy
349
+ * it; replies render indented under their root. `--json` does not use this.
350
+ * Pure → unit-testable.
351
+ */
352
+ export declare function formatChannelTranscript(messages: ChannelMessageItem[], nowMs: number): string;
300
353
  /**
301
354
  * The agent uid when the caller is an agent (machine) identity, else null.
302
355
  * Agents may not read /v1/notify/inbox or /v1/notify/thread (the server
@@ -515,6 +515,20 @@ async function fetchChannelMembers(token, channelId) {
515
515
  const data = (await res.json());
516
516
  return data.members ?? [];
517
517
  }
518
+ /** Fetch a channel history page (GET /v1/notify/channels/{id}/messages). Newest-first. */
519
+ async function fetchChannelMessages(token, channelId, query = {}) {
520
+ const res = await vaultApiFetch({
521
+ token,
522
+ path: `/v1/notify/channels/${encodeURIComponent(channelId)}/messages`,
523
+ query,
524
+ });
525
+ if (!res.ok) {
526
+ const err = (await res.json().catch(() => ({})));
527
+ throw new Error(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText));
528
+ }
529
+ const data = (await res.json());
530
+ return data.messages ?? [];
531
+ }
518
532
  /**
519
533
  * Channel DM path: `hq dm vyg-dev "msg"`, `hq dm '#vyg-dev' "msg"`, or
520
534
  * `hq dm --channel vyg-dev "msg"`. Resolves the caller's channel by name via
@@ -552,6 +566,20 @@ async function runChannelSend(channelName, message, opts) {
552
566
  process.exit(1);
553
567
  }
554
568
  const channel = matches[0];
569
+ let rootEventId;
570
+ if (opts.thread !== undefined) {
571
+ let recent = [];
572
+ if (opts.thread.trim().toLowerCase() === "last") {
573
+ recent = await fetchChannelMessages(token, channel.channelId);
574
+ }
575
+ const resolved = resolveThreadFlag(opts.thread, recent, callerFromToken(token));
576
+ if (!resolved.ok) {
577
+ console.error(chalk.red(resolved.error));
578
+ process.exit(1);
579
+ return;
580
+ }
581
+ rootEventId = resolved.rootEventId;
582
+ }
555
583
  // Mentions are structured-only server-side: resolve `@name` against the
556
584
  // roster and send the array, or fail before posting. --no-mentions opts out
557
585
  // and posts the text literally.
@@ -572,11 +600,16 @@ async function runChannelSend(channelName, message, opts) {
572
600
  // Send the human-readable form: the quotes only existed for parsing.
573
601
  if (mentions.length > 0)
574
602
  body = stripMentionQuotes(body);
603
+ const payload = { body };
604
+ if (mentions.length > 0)
605
+ payload.mentions = mentions;
606
+ if (rootEventId)
607
+ payload.rootEventId = rootEventId;
575
608
  const sendRes = await vaultApiFetch({
576
609
  token,
577
610
  path: `/v1/notify/channels/${encodeURIComponent(channel.channelId)}/messages`,
578
611
  method: "POST",
579
- body: mentions.length > 0 ? { body, mentions } : { body },
612
+ body: payload,
580
613
  });
581
614
  if (!sendRes.ok) {
582
615
  const err = (await sendRes.json().catch(() => ({})));
@@ -586,7 +619,8 @@ async function runChannelSend(channelName, message, opts) {
586
619
  const mentionSuffix = mentions.length > 0
587
620
  ? ` Mentioned ${mentions.map((m) => m.displayName).join(", ")}.`
588
621
  : "";
589
- console.log(chalk.green(`Message posted to #${channel.name ?? channelName}.${mentionSuffix}`));
622
+ const threadSuffix = rootEventId ? ` Reply in thread ${rootEventId}.` : "";
623
+ console.log(chalk.green(`Message posted to #${channel.name ?? channelName}.${mentionSuffix}${threadSuffix}`));
590
624
  }
591
625
  catch (err) {
592
626
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -608,6 +642,11 @@ async function runDmSend(recipient, message, opts) {
608
642
  // A comma in the recipient means a group DM — fan into the channel path.
609
643
  const group = parseGroupRecipients(recipient);
610
644
  if (group) {
645
+ if (opts.thread !== undefined) {
646
+ console.error(chalk.red("--thread is only supported for named channel messages (hq dm <channel> --thread <eventId|last> \"…\")."));
647
+ process.exit(1);
648
+ return;
649
+ }
611
650
  await runGroupSend(group, message);
612
651
  return;
613
652
  }
@@ -617,6 +656,11 @@ async function runDmSend(recipient, message, opts) {
617
656
  await runChannelSend(channelName, message, opts);
618
657
  return;
619
658
  }
659
+ if (opts.thread !== undefined) {
660
+ console.error(chalk.red("--thread is only supported for channel messages (hq dm <channel> --thread <eventId|last> \"…\")."));
661
+ process.exit(1);
662
+ return;
663
+ }
620
664
  try {
621
665
  // Resolve prompt/details from inline text or a file.
622
666
  let prompt = opts.prompt;
@@ -804,10 +848,162 @@ export function formatThreadMessage(m, nowMs) {
804
848
  return `${arrow} ${chalk.bold(who)} ${when}\n ${firstLine(m.body)}${formatAttachments(m)}`;
805
849
  }
806
850
  /** Render one channel/group message line. */
807
- export function formatChannelMessage(m, nowMs) {
851
+ export function formatChannelMessage(m, nowMs, opts = {}) {
852
+ const indent = opts.indent ?? "";
808
853
  const who = chalk.bold(senderLabel(m));
809
854
  const when = chalk.dim(formatRelativeTime(m.createdAt, nowMs));
810
- return `${who} ${when}\n ${firstLine(m.body)}${formatAttachments(m)}`;
855
+ const id = opts.showEventId ? (m.eventId ?? m.messageId) : undefined;
856
+ const idPart = id ? ` ${chalk.dim(id)}` : "";
857
+ const bodyIndent = `${indent} `;
858
+ return `${indent}${who} ${when}${idPart}\n${bodyIndent}${firstLine(m.body)}${formatAttachments(m, bodyIndent)}`;
859
+ }
860
+ /**
861
+ * Read the caller's person/agent uid and email from a JWT we already hold.
862
+ * Used to resolve `--thread last` (most recent top-level message *by us*).
863
+ * Pure → unit-testable.
864
+ */
865
+ export function callerFromToken(token) {
866
+ const claims = peekIdToken(token);
867
+ const uid = claims["custom:entityUid"];
868
+ const email = claims.email;
869
+ const personUid = typeof uid === "string" && uid.trim() ? uid.trim() : undefined;
870
+ const emailNorm = typeof email === "string" && email.trim()
871
+ ? email.trim().toLowerCase()
872
+ : undefined;
873
+ return {
874
+ ...(personUid ? { personUid } : {}),
875
+ ...(emailNorm ? { email: emailNorm } : {}),
876
+ };
877
+ }
878
+ export function isCallerMessage(m, me) {
879
+ if (me.personUid && m.fromPersonUid === me.personUid)
880
+ return true;
881
+ if (me.email && (m.fromEmail ?? "").trim().toLowerCase() === me.email) {
882
+ return true;
883
+ }
884
+ return false;
885
+ }
886
+ function messageEventId(m) {
887
+ const id = (m.eventId ?? m.messageId ?? "").trim();
888
+ return id || undefined;
889
+ }
890
+ /**
891
+ * Newest-first channel history → the caller's most recent top-level eventId.
892
+ * Replies (those with rootEventId) are skipped. Pure → unit-testable.
893
+ */
894
+ export function lastCallerRootEventId(messagesNewestFirst, me) {
895
+ for (const m of messagesNewestFirst) {
896
+ if ((m.rootEventId ?? "").trim())
897
+ continue;
898
+ if (!isCallerMessage(m, me))
899
+ continue;
900
+ const id = messageEventId(m);
901
+ if (id)
902
+ return id;
903
+ }
904
+ return undefined;
905
+ }
906
+ /**
907
+ * Resolve `--thread <eventId|last>` against a newest-first message page.
908
+ * An explicit eventId is passed through; `last` picks our most recent
909
+ * top-level message. Pure → unit-testable (the fetch is the caller's job).
910
+ */
911
+ export function resolveThreadFlag(thread, messagesNewestFirst, me) {
912
+ if (thread === undefined)
913
+ return { ok: true };
914
+ const value = thread.trim();
915
+ if (!value) {
916
+ return { ok: false, error: "--thread requires an eventId or 'last'." };
917
+ }
918
+ if (value.toLowerCase() !== "last") {
919
+ return { ok: true, rootEventId: value };
920
+ }
921
+ if (!me.personUid && !me.email) {
922
+ return {
923
+ ok: false,
924
+ error: "Cannot resolve --thread last: the session token has no person uid or email. Pass --thread <eventId> from `hq dm channel`.",
925
+ };
926
+ }
927
+ const id = lastCallerRootEventId(messagesNewestFirst, me);
928
+ if (!id) {
929
+ return {
930
+ ok: false,
931
+ error: "No top-level message from you in this channel's recent history. Pass --thread <eventId> (copy it from `hq dm channel <name>`).",
932
+ };
933
+ }
934
+ return { ok: true, rootEventId: id };
935
+ }
936
+ function sortByCreatedAt(a, b) {
937
+ return (a.createdAt ?? "").localeCompare(b.createdAt ?? "");
938
+ }
939
+ /**
940
+ * Group a channel history page into roots with their replies underneath.
941
+ * Orphan replies (root not in this page) hang off a placeholder carrying the
942
+ * missing root's eventId. Pure → unit-testable.
943
+ */
944
+ export function groupChannelMessages(messages) {
945
+ const roots = [];
946
+ const repliesByRoot = new Map();
947
+ for (const m of messages) {
948
+ const rootId = (m.rootEventId ?? "").trim();
949
+ if (!rootId) {
950
+ roots.push(m);
951
+ continue;
952
+ }
953
+ const bucket = repliesByRoot.get(rootId) ?? [];
954
+ bucket.push(m);
955
+ repliesByRoot.set(rootId, bucket);
956
+ }
957
+ const used = new Set();
958
+ const threads = [];
959
+ roots.sort(sortByCreatedAt);
960
+ for (const root of roots) {
961
+ const id = messageEventId(root) ?? "";
962
+ if (id)
963
+ used.add(id);
964
+ threads.push({
965
+ root,
966
+ replies: (repliesByRoot.get(id) ?? []).slice().sort(sortByCreatedAt),
967
+ });
968
+ }
969
+ for (const [rootId, replies] of repliesByRoot) {
970
+ if (used.has(rootId))
971
+ continue;
972
+ const sorted = replies.slice().sort(sortByCreatedAt);
973
+ threads.push({
974
+ root: {
975
+ eventId: rootId,
976
+ body: "",
977
+ createdAt: sorted[0]?.createdAt ?? "",
978
+ },
979
+ replies: sorted,
980
+ missingRoot: true,
981
+ });
982
+ }
983
+ return threads;
984
+ }
985
+ /**
986
+ * Human channel history: each root prints its eventId so `--thread` can copy
987
+ * it; replies render indented under their root. `--json` does not use this.
988
+ * Pure → unit-testable.
989
+ */
990
+ export function formatChannelTranscript(messages, nowMs) {
991
+ const threads = groupChannelMessages(messages);
992
+ const blocks = [];
993
+ for (const t of threads) {
994
+ const parts = [];
995
+ if (t.missingRoot) {
996
+ parts.push(chalk.dim(`… ${t.root.eventId ?? ""} (root not in this page)`));
997
+ }
998
+ else {
999
+ parts.push(formatChannelMessage(t.root, nowMs, { showEventId: true }));
1000
+ }
1001
+ for (const reply of t.replies) {
1002
+ parts.push(formatChannelMessage(reply, nowMs, { indent: " " }));
1003
+ }
1004
+ blocks.push(parts.join("\n"));
1005
+ }
1006
+ return blocks.join("\n\n");
811
1007
  }
812
1008
  /** POST /v1/notify/inbox/ack — idempotently mark messages read. */
813
1009
  async function ackEvents(token, eventIds) {
@@ -1053,8 +1249,7 @@ async function runDmChannel(target, opts) {
1053
1249
  else {
1054
1250
  console.log(chalk.green(`${ordered.length} message${ordered.length === 1 ? "" : "s"}:`));
1055
1251
  const now = Date.now();
1056
- for (const m of ordered)
1057
- console.log(`\n${formatChannelMessage(m, now)}`);
1252
+ console.log(`\n${formatChannelTranscript(ordered, now)}`);
1058
1253
  }
1059
1254
  if (opts.markRead) {
1060
1255
  // messages are newest-first from the server; advance the read cursor to
@@ -1082,9 +1277,10 @@ export function registerDmCommand(program) {
1082
1277
  .description("Send and read direct messages, and manage connection requests.");
1083
1278
  dm
1084
1279
  .command("send [recipient] [message]", { isDefault: true, hidden: true })
1085
- .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.')
1280
+ .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.')
1086
1281
  .option("--channel <name>", "Post the message to one of your DM channels by name (e.g. --channel vyg-dev)")
1087
1282
  .option("--no-mentions", "Do not resolve `@name` in a channel message into structured mentions — post the text literally")
1283
+ .option("--thread <eventId>", 'Reply in a channel thread. Pass a root eventId from `hq dm channel`, or `last` for your most recent top-level message in that channel')
1088
1284
  .option("--prompt <text>", "Agent-context prompt the recipient can one-click copy into their agent (1:1 DMs only)")
1089
1285
  .option("--prompt-file <path>", "Read the agent prompt from a file (1:1 DMs only)")
1090
1286
  .option("--details <text>", "Longer detail shown in the recipient's DM detail window (1:1 DMs only)")
@@ -1117,7 +1313,7 @@ export function registerDmCommand(program) {
1117
1313
  dm
1118
1314
  .command("channel <target>")
1119
1315
  .alias("history")
1120
- .description("Show recent messages in a DM channel or group DM — by name, #name, or a channel id from `hq channels`.")
1316
+ .description("Show recent messages in a DM channel or group DM — by name, #name, or a channel id from `hq channels`. Roots print their eventId; replies indent under their root. --json is the raw oldest-first array (ungrouped).")
1121
1317
  .option("--limit <n>", "Max messages to fetch (server-capped)")
1122
1318
  .option("--mark-read", "Advance your read marker to the newest message")
1123
1319
  .option("--json", "Output raw JSON instead of a transcript")
@@ -2,7 +2,7 @@
2
2
  * hq whoami — displays current user or 'not logged in'
3
3
  */
4
4
  import { Command } from 'commander';
5
- import { type PlanLockStatus } from "../lib/billing/plan-lock.js";
5
+ import { type PlanLimitStatus } from "../lib/billing/plan-lock.js";
6
6
  export interface WhoamiTokenIdentity {
7
7
  email?: string;
8
8
  sub?: string;
@@ -25,6 +25,6 @@ export declare function resolveWhoamiIdentity(identity: WhoamiTokenIdentity): Wh
25
25
  * workspace is over its Starter limits, the one-line `Plan: Starter — …`
26
26
  * orientation when it is Starter and healthy, and nothing at all otherwise.
27
27
  */
28
- export declare function renderWhoamiPlanBlock(status: PlanLockStatus): string | null;
28
+ export declare function renderWhoamiPlanBlock(status: PlanLimitStatus): string | null;
29
29
  export declare function registerWhoamiCommand(program: Command): void;
30
30
  //# sourceMappingURL=whoami.d.ts.map
@@ -5,7 +5,7 @@ import chalk from 'chalk';
5
5
  import { loadCachedTokens, isExpiring, loadMachineCreds, } from '@indigoai-us/hq-cloud';
6
6
  import { peekIdToken as decodeIdToken } from "../utils/id-token.js";
7
7
  import { ensureCognitoToken, loadMachineCachedTokens, resolveCognitoTokenSource, } from "../utils/cognito-session.js";
8
- import { colorizePlanLimitNotice, fetchPlanLockStatus, renderPlanLine, renderPlanLimitNotice, } from "../lib/billing/plan-lock.js";
8
+ import { colorizePlanLimitNotice, fetchPlanLimitStatus, renderPlanLine, renderPlanLimitNotice, } from "../lib/billing/plan-lock.js";
9
9
  /**
10
10
  * Resolves the person represented by an ID token without pairing a delegated
11
11
  * email with the token subject of the delegating machine.
@@ -42,27 +42,27 @@ function peekIdToken(idToken) {
42
42
  };
43
43
  }
44
44
  /**
45
- * starter-plan-hard-limits / US-011 — read the workspace plan lock for
46
- * `--company <slug>`.
45
+ * starter-plan-hard-limits / US-011 — read the workspace plan-limit nag for
46
+ * `--company <slug>` (the bind notice).
47
47
  *
48
- * hq-pro owns the decision (`planLock` on GET /membership/me); this only reads
49
- * it. Best-effort by construction: no session, a network failure, an older
50
- * backend that sends no `planLock`, or a company the caller is not a member of
51
- * all resolve to null — reported as UNKNOWN and rendered as nothing, never as
52
- * a guessed lock or a guessed all-clear, and never as a command failure. The
53
- * command exists to answer "who am I"; the plan line is decoration on top.
48
+ * hq-pro owns the numbers (`GET /v1/billing/usage-limits`); this only reads
49
+ * them. Best-effort by construction: no session, a network failure, or a
50
+ * company the caller is not a member of all resolve to null — reported as
51
+ * UNKNOWN and rendered as nothing, never as a guessed over-limit or a guessed
52
+ * all-clear, and never as a command failure. The command exists to answer
53
+ * "who am I"; the plan line is decoration on top.
54
54
  */
55
- async function readPlanLock(company) {
55
+ async function readPlanLimits(company) {
56
56
  const slug = company?.trim();
57
57
  if (!slug)
58
58
  return null;
59
59
  try {
60
60
  // NEVER interactive: this runs inside `--json` (before the authenticated
61
- // check) and inside hq-core's per-turn plan-lock refresh hook. With the
61
+ // check) and inside hq-core's per-turn plan-limit refresh hook. With the
62
62
  // default `interactive: true`, a box with no cached session would launch a
63
63
  // browser sign-in and block the caller instead of resolving to UNKNOWN.
64
64
  const token = await ensureCognitoToken({ interactive: false });
65
- return await fetchPlanLockStatus(token, slug);
65
+ return await fetchPlanLimitStatus(token, slug);
66
66
  }
67
67
  catch {
68
68
  return null;
@@ -74,13 +74,13 @@ async function readPlanLock(company) {
74
74
  * orientation when it is Starter and healthy, and nothing at all otherwise.
75
75
  */
76
76
  export function renderWhoamiPlanBlock(status) {
77
- if (status.lock.locked) {
77
+ if (status.over) {
78
78
  return colorizePlanLimitNotice(renderPlanLimitNotice(status));
79
79
  }
80
80
  return renderPlanLine(status);
81
81
  }
82
82
  async function printPlanBlock(company) {
83
- const status = await readPlanLock(company);
83
+ const status = await readPlanLimits(company);
84
84
  if (!status)
85
85
  return;
86
86
  const block = renderWhoamiPlanBlock(status);
@@ -92,7 +92,7 @@ export function registerWhoamiCommand(program) {
92
92
  .command('whoami')
93
93
  .description('Show the currently authenticated user')
94
94
  .option('--json', 'Output identity metadata as JSON (never tokens)')
95
- .option('--company <slug>', 'Also report this company\'s workspace plan state (Starter lock)')
95
+ .option('--company <slug>', 'Also report this company\'s workspace plan state (Starter nag)')
96
96
  .action(async (opts) => {
97
97
  try {
98
98
  // Effective source (same as auth status / ensureCognitoToken): env
@@ -115,9 +115,8 @@ export function registerWhoamiCommand(program) {
115
115
  const agentUid = machineCreds?.entityUid !== undefined
116
116
  ? (machineCreds.entityUid.startsWith('agt_') ? machineCreds.entityUid : null)
117
117
  : boundLegacyAgent;
118
- // planLock is additive: absent-or-null means UNKNOWN, which is what
119
- // every consumer already sees from a backend that predates it.
120
- const planLock = await readPlanLock(opts.company);
118
+ // planLimits is additive: absent-or-null means UNKNOWN.
119
+ const planLimits = await readPlanLimits(opts.company);
121
120
  console.log(JSON.stringify({
122
121
  schemaVersion: 1,
123
122
  authenticated: machine ? Boolean(machineCreds) : Boolean(cached && !isExpiring(cached, 0)),
@@ -126,7 +125,7 @@ export function registerWhoamiCommand(program) {
126
125
  personUid: !machine && identity.entityType === 'person' ? identity.entityUid ?? null : null,
127
126
  agentUid,
128
127
  username: machineCreds?.username ?? null,
129
- planLock,
128
+ planLimits,
130
129
  }, null, 2));
131
130
  return;
132
131
  }