@indigoai-us/hq-cli 5.122.2 → 5.123.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +46 -0
  3. package/dist/command-catalog.generated.d.ts +6 -3
  4. package/dist/command-catalog.generated.js +7 -3
  5. package/dist/commands/dm.d.ts +54 -1
  6. package/dist/commands/dm.js +204 -8
  7. package/dist/commands/mesh.js +7 -2
  8. package/dist/commands/update.js +6 -0
  9. package/dist/commands/whoami.d.ts +2 -2
  10. package/dist/commands/whoami.js +18 -19
  11. package/dist/lib/billing/plan-lock.d.ts +64 -69
  12. package/dist/lib/billing/plan-lock.js +230 -161
  13. package/dist/lib/mesh/api.js +96 -28
  14. package/dist/lib/mesh/live/backfill-held.d.ts +43 -7
  15. package/dist/lib/mesh/live/backfill-held.js +323 -42
  16. package/dist/lib/mesh/live/daemon/board-refresh.d.ts +4 -1
  17. package/dist/lib/mesh/live/daemon/board-refresh.js +71 -30
  18. package/dist/lib/mesh/live/daemon/config.d.ts +9 -0
  19. package/dist/lib/mesh/live/daemon/config.js +45 -0
  20. package/dist/lib/mesh/live/daemon/credentials.d.ts +6 -1
  21. package/dist/lib/mesh/live/daemon/credentials.js +20 -4
  22. package/dist/lib/mesh/live/daemon/doctor.d.ts +6 -0
  23. package/dist/lib/mesh/live/daemon/doctor.js +29 -2
  24. package/dist/lib/mesh/live/daemon/index.d.ts +4 -2
  25. package/dist/lib/mesh/live/daemon/index.js +3 -2
  26. package/dist/lib/mesh/live/daemon/install.d.ts +27 -0
  27. package/dist/lib/mesh/live/daemon/install.js +54 -4
  28. package/dist/lib/mesh/live/daemon/run.d.ts +6 -0
  29. package/dist/lib/mesh/live/daemon/run.js +50 -3
  30. package/dist/lib/mesh/live/daemon/state.d.ts +3 -0
  31. package/dist/lib/mesh/live/daemon/version-drift.d.ts +5 -0
  32. package/dist/lib/mesh/live/daemon/version-drift.js +47 -0
  33. package/dist/lib/mesh/live/paths.d.ts +2 -0
  34. package/dist/lib/mesh/live/paths.js +4 -0
  35. package/dist/lib/work-context/reconcile.js +67 -6
  36. package/dist/lib/work-context/state.d.ts +5 -3
  37. package/dist/lib/work-context/state.js +28 -4
  38. package/dist/utils/cli-telemetry.d.ts +21 -0
  39. package/dist/utils/cli-telemetry.js +77 -1
  40. package/dist/utils/self-update.d.ts +3 -0
  41. package/dist/utils/self-update.js +11 -0
  42. package/package.json +3 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,65 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.123.1] — 2026-09-19
6
+
7
+ ### Fixed
8
+
9
+ - Work Mesh now preserves an acknowledged company binding through later
10
+ company-less hook writes, preventing repeated registrations and held events.
11
+ Held recovery accepts only durable registrations, canonical metadata under a
12
+ verified HQ root, or manifest-backed repository ownership; conflicting or
13
+ event-only hints stay unresolved. Its batch cursor rotates across restarts.
14
+ Both legacy and direct-mode daemons detect an upgraded CLI, and systemd units
15
+ use `Restart=always` so the installed service reloads the new code.
16
+
17
+ - Held-session recovery now accepts session metadata only from the daemon's
18
+ configured HQ root, requires current-session evidence before restoring an
19
+ acknowledged registration, and retains batch position when held records are
20
+ compacted. Repeated hook activity no longer re-registers an unchanged binding.
21
+ CLI updates report installed system units that need their host service manager
22
+ to restart them. Daemon installs persist verified HQ-root allowlists, inject
23
+ the primary root into service environments, and rewrite/reload existing user
24
+ units so upgraded daemons restart reliably.
25
+
26
+ ## [5.123.0] — 2026-09-19
27
+
28
+ ### Fixed
29
+
30
+ - `hq mesh check --project` lists that project's open threads. The CLI queries
31
+ `/v1/work-mesh/threads` with `projectId` (and `status=open`) and merges the
32
+ result with the status listing, because the status index can omit project
33
+ threads.
34
+
35
+ - First signed-in interactive run prints a one-line telemetry disclosure on
36
+ stderr (once per machine). `HQ_TELEMETRY=0` (also `false` or `off`) disables
37
+ CLI telemetry posts. After the notice has been shown, events send
38
+ `consentBasis: "disclosed"` instead of `"no-consent"`.
39
+
40
+ ### Added
41
+
42
+ - Channel messages can now be posted as thread replies: `hq dm <channel>
43
+ --thread <eventId> "…"` sends `rootEventId` on the POST, and `--thread last`
44
+ replies to your most recent top-level message in that channel. `hq dm channel
45
+ <name>` prints each root's eventId and indents replies under their root
46
+ (`--json` is still the ungrouped oldest-first array). `@name` mentions were
47
+ already resolved against the roster into the structured `mentions` array;
48
+ unknown names still fail the send and list the roster (`--no-mentions` posts
49
+ the text literally).
50
+
51
+ ### Changed
52
+
53
+ - `hq whoami --company` and the bind notice no longer read `planLock`. They
54
+ render the Starter over-limit nag from `GET /v1/billing/usage-limits`
55
+ (including the top-level `freeMonth` CTA). Customer copy does not say
56
+ "locked" or "read-only".
57
+
58
+ ### Fixed
59
+
60
+ - Work Mesh board snapshots now read live story status from ProjectView before
61
+ falling back to the vault board, and `hq mesh daemon doctor` warns when a
62
+ presence credential omitted companies.
63
+
5
64
  ## [5.122.2] — 2026-09-19
6
65
 
7
66
  ### 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")
@@ -6,6 +6,7 @@
6
6
  * Distinct from `hq doctor` (hook guardrails).
7
7
  */
8
8
  import * as os from "node:os";
9
+ import { findHqRoot } from "../utils/manifest.js";
9
10
  import chalk from "chalk";
10
11
  import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js";
11
12
  import * as readline from "node:readline/promises";
@@ -324,6 +325,7 @@ async function runContextBackfillHeld(opts) {
324
325
  const result = await backfillHeldSessions({
325
326
  workMeshRoot: meshRoot,
326
327
  workContextRoot: root,
328
+ hqRoot: process.env.HQ_ROOT?.trim() || findHqRoot(),
327
329
  dryRun,
328
330
  limit,
329
331
  // Mirror the reconcile closure's non-forcing hint in the dry-run predictor
@@ -1181,7 +1183,10 @@ async function runDaemonRun() {
1181
1183
  async function runDaemonInstall(opts) {
1182
1184
  const result = installDaemonService({
1183
1185
  platform: detectPlatform(),
1184
- paths: buildInstallPaths({ systemUser: opts.systemUser }),
1186
+ paths: buildInstallPaths({
1187
+ systemUser: opts.systemUser,
1188
+ hqRoot: process.env.HQ_ROOT?.trim() || findHqRoot(),
1189
+ }),
1185
1190
  systemUser: opts.systemUser,
1186
1191
  });
1187
1192
  if (opts.json) {
@@ -1242,7 +1247,7 @@ async function runDaemonStatus(opts) {
1242
1247
  }
1243
1248
  }
1244
1249
  async function runDaemonDoctorCmd(opts) {
1245
- const report = collectDaemonDoctor();
1250
+ const report = collectDaemonDoctor({ hqRoot: process.env.HQ_ROOT?.trim() || findHqRoot() });
1246
1251
  if (opts.json) {
1247
1252
  console.log(JSON.stringify({ ok: !report.unhealthy, ...report }, null, 2));
1248
1253
  return;
@@ -5,6 +5,7 @@
5
5
  import * as path from 'path';
6
6
  import { findHqRoot, readManifest, readLock, writeLock, getModulesDir } from '../utils/manifest.js';
7
7
  import { fetchRepo, pullRepo, getCurrentCommit, isRepo } from '../utils/git.js';
8
+ import { restartDaemonServiceIfInstalled } from "../lib/mesh/live/daemon/install.js";
8
9
  export function registerUpdateCommand(program) {
9
10
  program
10
11
  .command('update [module-name]')
@@ -49,6 +50,11 @@ export function registerUpdateCommand(program) {
49
50
  }
50
51
  }
51
52
  writeLock(hqRoot, lock);
53
+ // An installed daemon is a separate Node process. Refresh it
54
+ // best-effort after a successful update without making the update fail.
55
+ const restart = restartDaemonServiceIfInstalled();
56
+ if (restart.message)
57
+ console.log(restart.message);
52
58
  console.log('\nLock file updated.');
53
59
  }
54
60
  catch (error) {
@@ -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