@indigoai-us/hq-cli 5.108.11 → 5.108.12

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,8 +2,54 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.108.12] - 2026-09-05
6
+
7
+ ### Fixed
8
+
9
+ - `hq dm send` into a channel now turns `@name` in the message into a REAL
10
+ mention. HQ Rooms mentions are structured-only — the server never reads
11
+ `@name` out of the body — so until now a CLI post like
12
+ `hq dm send '#hq-dev' "@Izzy please look"` went in with no `mentions` array
13
+ and mentioned nobody: no notification, no agent wake. During an incident that
14
+ was a silent false negative, and it made agent-in-channel workflows
15
+ undrivable from the CLI. The send path now reads the channel roster
16
+ (`GET /v1/notify/channels/{id}/members`), resolves each `@name`
17
+ case-insensitively against display name, slug/handle, email local part, and
18
+ first name, and posts the structured `mentions` array with `participantType`
19
+ derived from the uid prefix (`agt_*` → agent, otherwise human). Quote a
20
+ multi-word name as `@"Jacob Posel"`. A name that is unknown or matches more
21
+ than one member FAILS the send and lists the roster or the candidates rather
22
+ than posting something that looks addressed but isn't; a bare `@` or an email
23
+ address in prose is never a mention. `--no-mentions` posts the text literally.
24
+
5
25
  ## [5.108.11] — 2026-09-05
6
26
 
27
+ ### Fixed
28
+
29
+ - `hq doctor` agents-v2 (hermes) self-attestation now keys hook-adapter wiring
30
+ on the **runtime config**, not `.claude/settings.json` (#512). The 5.108.10
31
+ fix required `.claude/settings.json` to name `hq-agents-v2-hook-adapter.sh`,
32
+ but on a real hermes box it never does: the v2 runtime's shell-hook dispatcher
33
+ reads a `hooks:` block from `~/.hermes/config.yaml` that invokes the adapter,
34
+ and the adapter merely *reads* `.claude/settings.json` to fan out to the
35
+ classic `hook-gate.sh` hooks. On the v2.17 canary `settings.json` had 0
36
+ adapter references / 94 `hook-gate.sh` references while `~/.hermes/config.yaml`
37
+ wired the adapter across its lifecycle events, so the old check could never
38
+ attest. The runtime probe now requires the on-box adapter installed under the
39
+ tree at `.agents-v2-hooks/hq-agents-v2-hook-adapter.sh` **and** the runtime
40
+ config at `${HQ_HERMES_CONFIG_FILE:-~/.hermes/config.yaml}` invoking it. All
41
+ three attestation signals are still required, and no non-agents-v2 host's
42
+ verdict changes. TypeScript twin of `hq_runtime_config_wires_v2_adapter` in
43
+ `check-hq-hooks.sh`.
44
+ - `hq` self-update and the version-gate now name a non-writable npm prefix
45
+ plainly instead of failing opaquely (#511). When a global update fails because
46
+ the npm prefix is root-owned and the running user cannot write it — the
47
+ agent-box case, where the CLI is a `/usr` global install and the runtime is
48
+ unprivileged — both update surfaces now print that the prefix is not writable
49
+ and point at the paths that *can* update it (the box's `hq-cli-update` timer or
50
+ `sudo npm install -g @indigoai-us/hq-cli@latest`), rather than leaving
51
+ `exit 75` to read as a generic error.
52
+
7
53
  ## [5.108.10] — 2026-09-05
8
54
 
9
55
  ### Fixed
@@ -57,6 +57,70 @@ export interface ChannelSummary {
57
57
  * unit-testable.
58
58
  */
59
59
  export declare function matchChannelsByName(channels: ChannelSummary[], name: string): ChannelSummary[];
60
+ /** Server-side cap on one post's mentions (hq-pro MAX_CHANNEL_MENTIONS). */
61
+ export declare const MAX_CHANNEL_MENTIONS = 25;
62
+ /**
63
+ * One roster row from GET /v1/notify/channels/{id}/members. The server enriches
64
+ * each CHAN_MEMBER row with live display identity (displayName, email, …).
65
+ */
66
+ export interface ChannelMember {
67
+ personUid: string;
68
+ displayName?: string;
69
+ email?: string;
70
+ slug?: string;
71
+ handle?: string;
72
+ }
73
+ /** A structured mention as the POST messages route expects it. */
74
+ export interface ChannelMention {
75
+ participantUid: string;
76
+ participantType: "agent" | "human";
77
+ displayName: string;
78
+ }
79
+ /**
80
+ * `agt_*` uids are agents, everything else (`prs_*`) is a human. The server
81
+ * 400s INVALID_MENTIONS when the type and the uid prefix disagree, so this is
82
+ * derived, never guessed.
83
+ */
84
+ export declare function mentionParticipantType(uid: string): "agent" | "human";
85
+ /**
86
+ * Extract `@name` tokens from a message body — leading or inline. A token is an
87
+ * `@` at a WORD BOUNDARY (start of body, or after whitespace or an opening
88
+ * bracket/quote) followed by a name run. Quote the `@` for a multi-word display
89
+ * name: `@"Jacob Posel"`. Pure → unit-testable.
90
+ *
91
+ * 'hi @Izzy please look' → ["Izzy"]
92
+ * '@"Jacob Posel" ping' → ["Jacob Posel"]
93
+ * 'email a@b.com' → [] (not a word boundary)
94
+ * 'cost is @ the top' → [] (bare @)
95
+ * '@izzy and @Izzy' → ["izzy"] (deduped case-insensitively)
96
+ *
97
+ * Trailing punctuation is trimmed ("@Izzy," → "Izzy"), so a name is never
98
+ * polluted by the sentence around it.
99
+ */
100
+ export declare function parseMentionTokens(body: string): string[];
101
+ /**
102
+ * The names one roster row answers to, lowercased: its display name, the slug of
103
+ * that display name, any server-supplied slug/handle, the email local part, and
104
+ * the first word of a multi-word display name (so `@Izzy` reaches "Izzy
105
+ * Rivera"). A key shared by two rows makes the token AMBIGUOUS, which fails the
106
+ * send — never a silent pick. Pure → unit-testable.
107
+ */
108
+ export declare function memberMatchKeys(member: ChannelMember): string[];
109
+ export type MentionResolution = {
110
+ ok: true;
111
+ mentions: ChannelMention[];
112
+ } | {
113
+ ok: false;
114
+ error: string;
115
+ };
116
+ /**
117
+ * Resolve `@name` tokens against the channel roster into the structured
118
+ * `mentions` array. Case-insensitive on display name and slug/handle. Anything
119
+ * ambiguous (several roster rows answer to the name) or unresolvable (none do)
120
+ * FAILS with a message naming the candidates or the roster — the caller must
121
+ * never post a message whose "@" text mentions nobody. Pure → unit-testable.
122
+ */
123
+ export declare function resolveMentions(tokens: string[], members: ChannelMember[]): MentionResolution;
60
124
  /**
61
125
  * Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
62
126
  * Returns null on anything that doesn't match. Pure → unit-testable.
@@ -101,6 +101,157 @@ export function matchChannelsByName(channels, name) {
101
101
  return false;
102
102
  });
103
103
  }
104
+ // ─── Channel @mentions ───────────────────────────────────────────────────────
105
+ //
106
+ // HQ Rooms mentions are STRUCTURED ONLY: the server never parses `@name` out of
107
+ // the message body (see hq-pro src/vault-service/lib/room-agent-delivery.ts and
108
+ // parseChannelMentions / resolveChannelMentions in
109
+ // src/vault-service/handlers/notify-dm.ts). A post whose body says "@Izzy please
110
+ // look" but carries no `mentions` array mentions NOBODY — no notification, no
111
+ // agent wake. That is a silent false negative during an incident, so the CLI
112
+ // resolves the names itself and sends the structured array. Resolution failures
113
+ // FAIL the send rather than posting a message that looks addressed but isn't.
114
+ /** Server-side cap on one post's mentions (hq-pro MAX_CHANNEL_MENTIONS). */
115
+ export const MAX_CHANNEL_MENTIONS = 25;
116
+ /**
117
+ * `agt_*` uids are agents, everything else (`prs_*`) is a human. The server
118
+ * 400s INVALID_MENTIONS when the type and the uid prefix disagree, so this is
119
+ * derived, never guessed.
120
+ */
121
+ export function mentionParticipantType(uid) {
122
+ return uid.startsWith("agt_") ? "agent" : "human";
123
+ }
124
+ /**
125
+ * Extract `@name` tokens from a message body — leading or inline. A token is an
126
+ * `@` at a WORD BOUNDARY (start of body, or after whitespace or an opening
127
+ * bracket/quote) followed by a name run. Quote the `@` for a multi-word display
128
+ * name: `@"Jacob Posel"`. Pure → unit-testable.
129
+ *
130
+ * 'hi @Izzy please look' → ["Izzy"]
131
+ * '@"Jacob Posel" ping' → ["Jacob Posel"]
132
+ * 'email a@b.com' → [] (not a word boundary)
133
+ * 'cost is @ the top' → [] (bare @)
134
+ * '@izzy and @Izzy' → ["izzy"] (deduped case-insensitively)
135
+ *
136
+ * Trailing punctuation is trimmed ("@Izzy," → "Izzy"), so a name is never
137
+ * polluted by the sentence around it.
138
+ */
139
+ export function parseMentionTokens(body) {
140
+ const pattern = /(^|[\s([{<])@(?:"([^"\n]{1,80})"|([A-Za-z0-9][A-Za-z0-9._-]*))/g;
141
+ const names = [];
142
+ const seen = new Set();
143
+ let match;
144
+ while ((match = pattern.exec(body)) !== null) {
145
+ const raw = match[2] ?? match[3] ?? "";
146
+ // Only a bare name run can pick up sentence punctuation; a quoted name is
147
+ // taken exactly as written.
148
+ const name = (match[2] !== undefined ? raw : raw.replace(/[._-]+$/, "")).trim();
149
+ if (!name)
150
+ continue;
151
+ const key = name.toLowerCase();
152
+ if (seen.has(key))
153
+ continue;
154
+ seen.add(key);
155
+ names.push(name);
156
+ }
157
+ return names;
158
+ }
159
+ /**
160
+ * The names one roster row answers to, lowercased: its display name, the slug of
161
+ * that display name, any server-supplied slug/handle, the email local part, and
162
+ * the first word of a multi-word display name (so `@Izzy` reaches "Izzy
163
+ * Rivera"). A key shared by two rows makes the token AMBIGUOUS, which fails the
164
+ * send — never a silent pick. Pure → unit-testable.
165
+ */
166
+ export function memberMatchKeys(member) {
167
+ const keys = new Set();
168
+ const add = (value) => {
169
+ const v = (value ?? "").trim().toLowerCase();
170
+ if (v)
171
+ keys.add(v);
172
+ };
173
+ const displayName = (member.displayName ?? "").trim();
174
+ add(displayName);
175
+ add(channelSlug(displayName));
176
+ add(member.slug);
177
+ add(member.handle);
178
+ add((member.email ?? "").split("@")[0]);
179
+ const firstWord = displayName.split(/\s+/)[0];
180
+ if (firstWord && firstWord !== displayName) {
181
+ add(firstWord);
182
+ add(channelSlug(firstWord));
183
+ }
184
+ return [...keys];
185
+ }
186
+ /**
187
+ * Resolve `@name` tokens against the channel roster into the structured
188
+ * `mentions` array. Case-insensitive on display name and slug/handle. Anything
189
+ * ambiguous (several roster rows answer to the name) or unresolvable (none do)
190
+ * FAILS with a message naming the candidates or the roster — the caller must
191
+ * never post a message whose "@" text mentions nobody. Pure → unit-testable.
192
+ */
193
+ export function resolveMentions(tokens, members) {
194
+ if (tokens.length === 0)
195
+ return { ok: true, mentions: [] };
196
+ const byKey = new Map();
197
+ for (const member of members) {
198
+ if (!member.personUid)
199
+ continue;
200
+ for (const key of memberMatchKeys(member)) {
201
+ const bucket = byKey.get(key);
202
+ if (bucket)
203
+ bucket.push(member);
204
+ else
205
+ byKey.set(key, [member]);
206
+ }
207
+ }
208
+ const rosterNames = members
209
+ .map((m) => (m.displayName ?? "").trim() || m.personUid)
210
+ .filter(Boolean)
211
+ .sort((a, b) => a.localeCompare(b));
212
+ const mentions = [];
213
+ const claimed = new Set();
214
+ for (const token of tokens) {
215
+ const matches = byKey.get(token.trim().toLowerCase()) ?? [];
216
+ const unique = [...new Map(matches.map((m) => [m.personUid, m])).values()];
217
+ if (unique.length === 0) {
218
+ return {
219
+ ok: false,
220
+ error: `No channel member matches '@${token}'. Members: ` +
221
+ `${rosterNames.join(", ") || "(none)"}. ` +
222
+ `Fix the name, or pass --no-mentions to post the text literally.`,
223
+ };
224
+ }
225
+ if (unique.length > 1) {
226
+ const candidates = unique
227
+ .map((m) => `${(m.displayName ?? "").trim() || m.personUid} (${m.personUid})`)
228
+ .join(", ");
229
+ return {
230
+ ok: false,
231
+ error: `'@${token}' is ambiguous — it matches ${unique.length} members: ${candidates}. ` +
232
+ `Use the full name in quotes (e.g. @"Full Name"), or pass --no-mentions ` +
233
+ `to post the text literally.`,
234
+ };
235
+ }
236
+ const member = unique[0];
237
+ if (claimed.has(member.personUid))
238
+ continue;
239
+ claimed.add(member.personUid);
240
+ mentions.push({
241
+ participantUid: member.personUid,
242
+ participantType: mentionParticipantType(member.personUid),
243
+ displayName: (member.displayName ?? "").trim() || member.personUid,
244
+ });
245
+ }
246
+ if (mentions.length > MAX_CHANNEL_MENTIONS) {
247
+ return {
248
+ ok: false,
249
+ error: `That message mentions ${mentions.length} members; the server accepts at most ` +
250
+ `${MAX_CHANNEL_MENTIONS}. Trim the mentions, or pass --no-mentions.`,
251
+ };
252
+ }
253
+ return { ok: true, mentions };
254
+ }
104
255
  /**
105
256
  * Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
106
257
  * Returns null on anything that doesn't match. Pure → unit-testable.
@@ -336,6 +487,23 @@ async function fetchChannels(token) {
336
487
  const data = (await res.json());
337
488
  return data.channels ?? [];
338
489
  }
490
+ /**
491
+ * Fetch a channel's roster (GET /v1/notify/channels/{id}/members). Any member
492
+ * who can read the channel may read its members, so this is the same gate the
493
+ * post itself passes. Used to resolve `@name` mentions into participant uids.
494
+ */
495
+ async function fetchChannelMembers(token, channelId) {
496
+ const res = await vaultApiFetch({
497
+ token,
498
+ path: `/v1/notify/channels/${encodeURIComponent(channelId)}/members`,
499
+ });
500
+ if (!res.ok) {
501
+ const err = (await res.json().catch(() => ({})));
502
+ throw new Error(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText));
503
+ }
504
+ const data = (await res.json());
505
+ return data.members ?? [];
506
+ }
339
507
  /**
340
508
  * Channel DM path: `hq dm vyg-dev "msg"`, `hq dm '#vyg-dev' "msg"`, or
341
509
  * `hq dm --channel vyg-dev "msg"`. Resolves the caller's channel by name via
@@ -373,18 +541,38 @@ async function runChannelSend(channelName, message, opts) {
373
541
  process.exit(1);
374
542
  }
375
543
  const channel = matches[0];
544
+ // Mentions are structured-only server-side: resolve `@name` against the
545
+ // roster and send the array, or fail before posting. --no-mentions opts out
546
+ // and posts the text literally.
547
+ let mentions = [];
548
+ if (opts.mentions !== false) {
549
+ const tokens = parseMentionTokens(body);
550
+ if (tokens.length > 0) {
551
+ const members = await fetchChannelMembers(token, channel.channelId);
552
+ const resolved = resolveMentions(tokens, members);
553
+ if (!resolved.ok) {
554
+ console.error(chalk.red(resolved.error));
555
+ process.exit(1);
556
+ return;
557
+ }
558
+ mentions = resolved.mentions;
559
+ }
560
+ }
376
561
  const sendRes = await vaultApiFetch({
377
562
  token,
378
563
  path: `/v1/notify/channels/${encodeURIComponent(channel.channelId)}/messages`,
379
564
  method: "POST",
380
- body: { body },
565
+ body: mentions.length > 0 ? { body, mentions } : { body },
381
566
  });
382
567
  if (!sendRes.ok) {
383
568
  const err = (await sendRes.json().catch(() => ({})));
384
569
  console.error(chalk.red(friendlyDmError(sendRes.status, err.code, err.error ?? err.message ?? sendRes.statusText)));
385
570
  process.exit(1);
386
571
  }
387
- console.log(chalk.green(`Message posted to #${channel.name ?? channelName}.`));
572
+ const mentionSuffix = mentions.length > 0
573
+ ? ` Mentioned ${mentions.map((m) => m.displayName).join(", ")}.`
574
+ : "";
575
+ console.log(chalk.green(`Message posted to #${channel.name ?? channelName}.${mentionSuffix}`));
388
576
  }
389
577
  catch (err) {
390
578
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -781,8 +969,9 @@ export function registerDmCommand(program) {
781
969
  .description("Send and read direct messages, and manage connection requests.");
782
970
  dm
783
971
  .command("send [recipient] [message]", { isDefault: true, hidden: true })
784
- .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`.')
972
+ .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.')
785
973
  .option("--channel <name>", "Post the message to one of your DM channels by name (e.g. --channel vyg-dev)")
974
+ .option("--no-mentions", "Do not resolve `@name` in a channel message into structured mentions — post the text literally")
786
975
  .option("--prompt <text>", "Agent-context prompt the recipient can one-click copy into their agent (1:1 DMs only)")
787
976
  .option("--prompt-file <path>", "Read the agent prompt from a file (1:1 DMs only)")
788
977
  .option("--details <text>", "Longer detail shown in the recipient's DM detail window (1:1 DMs only)")
@@ -128,8 +128,17 @@ export interface ClientHealthHeartbeat {
128
128
  syncState: ClientHealthSyncState;
129
129
  /** Last time a sync RUN started — distinct from success. */
130
130
  lastSyncAttemptAt?: string;
131
- /** Advances only on genuine success (including no-change runs). */
131
+ /** Advances only on a genuine COMPLETED run (including no-change runs). Never the engine watermark. */
132
132
  lastSyncSuccessAt?: string;
133
+ /**
134
+ * Sync-engine per-file journal high-water mark (hq-cloud `journal.lastSync`).
135
+ * Advances whenever ANY file moves — including on a run that later FAILS —
136
+ * so it is NOT proof a run completed and must never be read as a completed
137
+ * success. Distinct from `lastSyncSuccessAt`, and never folded into it.
138
+ * Absent on clients with no local sync engine (desktop) and on clients that
139
+ * predate this field — absence means "unknown", not "no activity".
140
+ */
141
+ syncEngineWatermarkAt?: string;
133
142
  consecutiveFailures: number;
134
143
  conflictCount?: number;
135
144
  updaterState?: ClientHealthUpdaterState;
@@ -253,6 +253,8 @@ export function parseClientHealthHeartbeat(input) {
253
253
  heartbeat.lastSyncAttemptAt = assertIsoUtc("lastSyncAttemptAt", raw.lastSyncAttemptAt);
254
254
  if (raw.lastSyncSuccessAt !== undefined)
255
255
  heartbeat.lastSyncSuccessAt = assertIsoUtc("lastSyncSuccessAt", raw.lastSyncSuccessAt);
256
+ if (raw.syncEngineWatermarkAt !== undefined)
257
+ heartbeat.syncEngineWatermarkAt = assertIsoUtc("syncEngineWatermarkAt", raw.syncEngineWatermarkAt);
256
258
  if (raw.conflictCount !== undefined) {
257
259
  heartbeat.conflictCount = assertBoundedInt("conflictCount", raw.conflictCount, CLIENT_HEALTH_MAX_CONFLICT_COUNT);
258
260
  }
@@ -335,7 +335,13 @@ export function buildCliHeartbeat(input) {
335
335
  if (input.versions.sync && SEMVER.test(input.versions.sync)) {
336
336
  versions.desktop = input.versions.sync;
337
337
  }
338
- const lastSyncSuccessAt = latestIso(input.state.lastSyncSuccessAt, input.journalLastSyncAt);
338
+ // `lastSyncSuccessAt` is ONLY this CLI's own completed-run success — never
339
+ // the engine journal watermark. The watermark is carried on its own field
340
+ // (`syncEngineWatermarkAt`) so support can tell a completed run from mere
341
+ // per-file engine movement; folding the two would fabricate a completed-run
342
+ // success out of a run that may have failed (US-019).
343
+ const lastSyncSuccessAt = input.state.lastSyncSuccessAt ?? null;
344
+ const syncEngineWatermarkAt = input.journalLastSyncAt;
339
345
  let syncState;
340
346
  switch (input.kind) {
341
347
  case "sync_attempt":
@@ -351,17 +357,20 @@ export function buildCliHeartbeat(input) {
351
357
  // overwrote the `error` state the failing sync had reported while
352
358
  // leaving the streak that earned it untouched.
353
359
  //
354
- // Only this CLI's own `sync_success` clears the streak. The journal
355
- // `lastSync` folded into `lastSyncSuccessAt` below deliberately does
356
- // NOT: the engine stamps it per FILE update (hq-cloud journal.ts
357
- // `updateEntry`), and a push stamps it before throwing its upload worker
358
- // errors — so it means "some file moved", not "a run succeeded", and
359
- // must never clear an alarm counter. That is why this branch keys off
360
- // the streak rather than off the success timestamp.
360
+ // Only this CLI's own `sync_success` clears the streak. The engine
361
+ // journal watermark (`syncEngineWatermarkAt`) deliberately does NOT: the
362
+ // engine stamps it per FILE update (hq-cloud journal.ts `updateEntry`),
363
+ // and a push stamps it before throwing its upload worker errors — so it
364
+ // means "some file moved", not "a run succeeded", and must never clear an
365
+ // alarm counter. `never_synced` still keys off ANY observed sync activity
366
+ // (a completed run OR engine movement); it only means this installation
367
+ // has never moved a byte.
361
368
  if (input.state.consecutiveFailures > 0)
362
369
  syncState = "error";
363
- else
364
- syncState = lastSyncSuccessAt ? "idle" : "never_synced";
370
+ else {
371
+ const hasSyncedSomething = lastSyncSuccessAt !== null || syncEngineWatermarkAt !== null;
372
+ syncState = hasSyncedSomething ? "idle" : "never_synced";
373
+ }
365
374
  break;
366
375
  }
367
376
  const heartbeat = {
@@ -382,20 +391,15 @@ export function buildCliHeartbeat(input) {
382
391
  if (lastSyncSuccessAt !== null) {
383
392
  heartbeat.lastSyncSuccessAt = lastSyncSuccessAt;
384
393
  }
394
+ if (syncEngineWatermarkAt !== null) {
395
+ heartbeat.syncEngineWatermarkAt = syncEngineWatermarkAt;
396
+ }
385
397
  if ((input.kind === "sync_success" || input.kind === "sync_failure") &&
386
398
  input.localFilesOverview) {
387
399
  heartbeat.localFilesOverview = input.localFilesOverview;
388
400
  }
389
401
  return heartbeat;
390
402
  }
391
- function latestIso(a, b) {
392
- const times = [a, b]
393
- .map((value) => (typeof value === "string" ? Date.parse(value) : NaN))
394
- .filter((value) => Number.isFinite(value));
395
- if (times.length === 0)
396
- return null;
397
- return new Date(Math.max(...times)).toISOString();
398
- }
399
403
  const defaultPoster = (heartbeat, token, signal) => vaultApiFetch({
400
404
  token,
401
405
  path: "/v1/client-health/heartbeat",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.108.11",
3
+ "version": "5.108.12",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {