@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
@@ -11,6 +11,7 @@
11
11
  import * as fs from "node:fs";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
+ import { CLI_VERSION } from "../../../../cli-version.js";
14
15
  import { createWorkSessionDeliverer } from "../../client.js";
15
16
  import { meshJson, resolveActiveMembershipCompany } from "../../api.js";
16
17
  import { ensureCognitoToken } from "../../../../utils/cognito-session.js";
@@ -31,10 +32,14 @@ import { defaultDaemonState, patchDaemonState, writeDaemonState, } from "./state
31
32
  import { workMeshHeldPath } from "../paths.js";
32
33
  import { noteHookSessionsFromSpoolFile, TRANSCRIPT_WATCH_INTERVAL_MS, TranscriptWatcher, } from "./transcript-watch.js";
33
34
  import { resolveMeshEmitMode, runsEmitPath, } from "./mode.js";
35
+ import { hasCliVersionDrift, readInstalledCliVersion } from "./version-drift.js";
36
+ import { readDaemonConfig } from "./config.js";
34
37
  export const SPOOL_DEBOUNCE_MS = 2_000;
35
38
  export const FLUSH_INTERVAL_MS = 10_000;
36
39
  /** Keep startup recovery bounded: the next daemon start continues the backlog. */
37
40
  export const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
41
+ /** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
42
+ export const DAEMON_VERSION_DRIFT_EXIT_CODE = 75;
38
43
  /**
39
44
  * Only producer appends to spool.jsonl should wake the watcher. Every other
40
45
  * file in the work-mesh root (held.jsonl, spool.<ts>.claimed, held.<ts>.claimed,
@@ -76,7 +81,15 @@ export async function runMeshDaemon(deps = {}) {
76
81
  ((d, line) => appendDaemonLog(d, daemonLogLine("info", line, now)));
77
82
  const processEvents = deps.processEvents ?? process;
78
83
  const exit = deps.exit ?? ((code) => process.exit(code));
84
+ const daemonVersion = deps.daemonVersion ?? CLI_VERSION;
85
+ const installedCliVersion = deps.readInstalledCliVersion ?? readInstalledCliVersion;
79
86
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
87
+ const configuredRoots = readDaemonConfig(dir).trustedHqRoots;
88
+ const trustedRoots = [...new Set([
89
+ ...(deps.hqRoot ? [deps.hqRoot] : []),
90
+ ...(env.HQ_ROOT?.trim() ? [env.HQ_ROOT.trim()] : []),
91
+ ...configuredRoots,
92
+ ])];
80
93
  ensureDaemonLog(dir);
81
94
  const lockDeps = deps.pidLock ?? defaultPidLockDeps({ isProcessAlive: alive });
82
95
  const lock = acquirePidLock(dir, lockDeps);
@@ -132,6 +145,7 @@ export async function runMeshDaemon(deps = {}) {
132
145
  return backfillHeldSessions({
133
146
  workMeshRoot: meshRoot,
134
147
  workContextRoot: ctxRoot,
148
+ hqRoots: trustedRoots,
135
149
  limit: deps.heldBackfillBatchSize ?? HELD_BACKFILL_STARTUP_BATCH_SIZE,
136
150
  env,
137
151
  reconcile: (observation) => reconcileObservation(observation, {
@@ -162,7 +176,7 @@ export async function runMeshDaemon(deps = {}) {
162
176
  });
163
177
  let presence = null;
164
178
  if (deps.enablePresence !== false) {
165
- const fetchCredentials = deps.fetchCredentials ??
179
+ const fetchCredentialsRaw = deps.fetchCredentials ??
166
180
  (async () => {
167
181
  const t = await getToken();
168
182
  return createContract3Fetcher({
@@ -171,6 +185,11 @@ export async function runMeshDaemon(deps = {}) {
171
185
  env,
172
186
  })();
173
187
  });
188
+ const fetchCredentials = async () => {
189
+ const bundle = await fetchCredentialsRaw();
190
+ patchDaemonState(dir, { presenceDroppedCompanies: bundle.droppedCompanies }, now);
191
+ return bundle;
192
+ };
174
193
  const presenceOpts = {
175
194
  fetchCredentials,
176
195
  mqttConnect: deps.mqttConnect,
@@ -218,15 +237,28 @@ export async function runMeshDaemon(deps = {}) {
218
237
  }
219
238
  }
220
239
  let stopped = false;
240
+ let restartRequested = false;
221
241
  let flushInFlight = null;
222
242
  let debounceHandle = null;
223
243
  let flushIntervalHandle = null;
244
+ let versionIntervalHandle = null;
224
245
  let boardIntervalHandle = null;
225
246
  let transcriptIntervalHandle = null;
226
247
  let watcher = null;
227
248
  /** Shared with transcript watcher: sessionIds seen on the hook/spool path. */
228
249
  const lastHookEventAt = new Map();
229
250
  let transcriptWatcher = null;
251
+ const requestRestartForVersionDrift = () => {
252
+ if (stopped || restartRequested)
253
+ return restartRequested;
254
+ const installedVersion = installedCliVersion();
255
+ if (!hasCliVersionDrift(daemonVersion, installedVersion))
256
+ return false;
257
+ restartRequested = true;
258
+ log(dir, `cli upgraded ${daemonVersion} -> ${installedVersion}, restarting`);
259
+ exit(DAEMON_VERSION_DRIFT_EXIT_CODE);
260
+ return true;
261
+ };
230
262
  if (emitPath && deps.enableTranscriptWatch !== false) {
231
263
  const hqRoot = deps.hqRoot ?? env.HQ_ROOT?.trim() ?? undefined;
232
264
  const getToken = deps.getToken ?? defaultGetToken;
@@ -268,7 +300,12 @@ export async function runMeshDaemon(deps = {}) {
268
300
  }
269
301
  const doFlushCycle = async (reason, opts = {}) => {
270
302
  // Refuse new cycles after stop, except the forced shutdown flush.
271
- if (stopped && !opts.force)
303
+ if ((stopped || restartRequested) && !opts.force)
304
+ return;
305
+ // A long-lived daemon retains its loaded JS after a global CLI upgrade.
306
+ // The service unit resolves the binary again on restart, so exiting lets
307
+ // launchd/systemd bring up the upgraded implementation.
308
+ if (!opts.force && requestRestartForVersionDrift())
272
309
  return;
273
310
  if (flushInFlight) {
274
311
  await flushInFlight;
@@ -400,6 +437,14 @@ export async function runMeshDaemon(deps = {}) {
400
437
  boardIntervalHandle = timers.setTimeout(boardTick, BOARD_REFRESH_INTERVAL_MS);
401
438
  });
402
439
  }, BOARD_REFRESH_INTERVAL_MS);
440
+ // Version drift is independent of the legacy spool flush. Direct-mode
441
+ // daemons have no flush cycle, so they still need the same periodic restart
442
+ // check after an in-place CLI upgrade.
443
+ versionIntervalHandle = timers.setTimeout(function versionTick() {
444
+ if (stopped || requestRestartForVersionDrift())
445
+ return;
446
+ versionIntervalHandle = timers.setTimeout(versionTick, FLUSH_INTERVAL_MS);
447
+ }, FLUSH_INTERVAL_MS);
403
448
  const runTranscriptTick = async () => {
404
449
  if (!transcriptWatcher || stopped)
405
450
  return;
@@ -442,7 +487,7 @@ export async function runMeshDaemon(deps = {}) {
442
487
  }
443
488
  // Initial pass. Board refresh runs in both modes; the flush + transcript
444
489
  // scan are legacy emit-path only.
445
- if (emitPath) {
490
+ if (!requestRestartForVersionDrift() && emitPath) {
446
491
  void doFlushCycle("start");
447
492
  }
448
493
  void refreshFn().catch(() => undefined);
@@ -458,6 +503,8 @@ export async function runMeshDaemon(deps = {}) {
458
503
  timers.clearTimeout(debounceHandle);
459
504
  if (flushIntervalHandle !== null)
460
505
  timers.clearTimeout(flushIntervalHandle);
506
+ if (versionIntervalHandle !== null)
507
+ timers.clearTimeout(versionIntervalHandle);
461
508
  if (boardIntervalHandle !== null)
462
509
  timers.clearTimeout(boardIntervalHandle);
463
510
  if (transcriptIntervalHandle !== null) {
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import type { MqttConnectionState } from "./presence.js";
5
5
  import type { FlushSummary } from "../flush.js";
6
+ import type { DroppedPresenceCompany } from "./credentials.js";
6
7
  export interface PresenceRefusalState {
7
8
  code: string;
8
9
  status: number;
@@ -14,6 +15,8 @@ export interface DaemonStateFile {
14
15
  startedAt: string;
15
16
  mqttState: MqttConnectionState;
16
17
  companiesOnline: string[];
18
+ /** Companies omitted from the current presence credential bundle. */
19
+ presenceDroppedCompanies?: DroppedPresenceCompany[];
17
20
  actorUid?: string;
18
21
  lastFlushAt?: string;
19
22
  lastFlushResult?: FlushSummary & {
@@ -0,0 +1,5 @@
1
+ /** Version installed behind the entrypoint that launched this daemon. */
2
+ export declare function readInstalledCliVersion(entry?: string): string | null;
3
+ /** A missing/unreadable installed version is not proof of a safe restart. */
4
+ export declare function hasCliVersionDrift(daemonVersion: string, installedVersion: string | null | undefined): installedVersion is string;
5
+ //# sourceMappingURL=version-drift.d.ts.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Read the version of the CLI package currently selected by the daemon's
3
+ * launch entrypoint. The daemon keeps the version it was loaded with in
4
+ * memory; an npm/pnpm upgrade can repoint this entrypoint without restarting
5
+ * that process.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ function packageVersionAtOrAbove(entry) {
10
+ let dir;
11
+ try {
12
+ dir = path.dirname(fs.realpathSync(entry));
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ // A CLI entrypoint is normally <package>/dist/index.js. Keep the walk
18
+ // bounded so a malformed entrypoint never traverses an entire filesystem.
19
+ for (let i = 0; i < 8; i += 1) {
20
+ const pkgPath = path.join(dir, "package.json");
21
+ try {
22
+ const parsed = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
23
+ if (parsed.name === "@indigoai-us/hq-cli" &&
24
+ typeof parsed.version === "string" &&
25
+ parsed.version.trim()) {
26
+ return parsed.version.trim();
27
+ }
28
+ }
29
+ catch {
30
+ // No package manifest at this level; continue walking upward.
31
+ }
32
+ const parent = path.dirname(dir);
33
+ if (parent === dir)
34
+ break;
35
+ dir = parent;
36
+ }
37
+ return null;
38
+ }
39
+ /** Version installed behind the entrypoint that launched this daemon. */
40
+ export function readInstalledCliVersion(entry = process.argv[1]) {
41
+ return entry ? packageVersionAtOrAbove(entry) : null;
42
+ }
43
+ /** A missing/unreadable installed version is not proof of a safe restart. */
44
+ export function hasCliVersionDrift(daemonVersion, installedVersion) {
45
+ return Boolean(installedVersion && installedVersion !== daemonVersion);
46
+ }
47
+ //# sourceMappingURL=version-drift.js.map
@@ -6,6 +6,8 @@ export declare const WORK_MESH_DIRNAME = "work-mesh";
6
6
  export declare function workMeshRoot(home?: string, env?: NodeJS.ProcessEnv): string;
7
7
  export declare function workMeshSpoolPath(root?: string): string;
8
8
  export declare function workMeshHeldPath(root?: string): string;
9
+ /** Rotating cursor for bounded daemon-start held-event recovery. */
10
+ export declare function workMeshHeldBackfillCursorPath(root?: string): string;
9
11
  export declare function workMeshDeadLetterPath(root?: string): string;
10
12
  export declare function workMeshDroppedReceiptPath(root?: string): string;
11
13
  /** Claim path: spool.<ts>.claimed (never reuse spool.jsonl for claim). */
@@ -17,6 +17,10 @@ export function workMeshSpoolPath(root) {
17
17
  export function workMeshHeldPath(root) {
18
18
  return path.join(root ?? workMeshRoot(), "held.jsonl");
19
19
  }
20
+ /** Rotating cursor for bounded daemon-start held-event recovery. */
21
+ export function workMeshHeldBackfillCursorPath(root) {
22
+ return path.join(root ?? workMeshRoot(), "held-backfill-cursor.json");
23
+ }
20
24
  export function workMeshDeadLetterPath(root) {
21
25
  return path.join(root ?? workMeshRoot(), "dead-letter.jsonl");
22
26
  }
@@ -10,7 +10,7 @@ import { EXIT_INVALID_IDENTITY, EXIT_NOT_TRACKING, EXIT_OK, InvalidDecisionOrigi
10
10
  import { decisionFromCandidates, } from "./organize.js";
11
11
  import { resolveProjectTask, shouldAskAfter } from "./project.js";
12
12
  import { deriveRemoteOwnerSlug, deriveRepoIdentityKey } from "./repo-remote.js";
13
- import { enqueueOutbox, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, writeAckedRegisterState, } from "./outbox.js";
13
+ import { enqueueOutbox, listOutboxOperations, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, writeAckedRegisterState, } from "./outbox.js";
14
14
  import { mergeLocalSessionFields, readSessionState, writeSessionState, } from "./state.js";
15
15
  function resultOf(parts) {
16
16
  const out = {
@@ -293,12 +293,10 @@ export async function reconcileObservation(obs, deps) {
293
293
  startedAt,
294
294
  ...(typeof toolWrites === "number" ? { toolWrites } : {}),
295
295
  ...(hasTurn ? { hasTurn: true } : {}),
296
- // Preserve prior binding ids for visibility; do not auto-switch.
296
+ // Preserve prior scope for visibility; its receipt belongs to the
297
+ // quarantined binding and must not suppress a later retry.
297
298
  ...(prior?.projectId ? { projectId: prior.projectId } : {}),
298
299
  ...(prior?.taskId ? { taskId: prior.taskId } : {}),
299
- ...(prior?.bindingEpisodeId
300
- ? { bindingEpisodeId: prior.bindingEpisodeId }
301
- : {}),
302
300
  ...transcriptFields,
303
301
  decision: {
304
302
  decisionId: `conflict_${sessionId}`,
@@ -325,7 +323,7 @@ export async function reconcileObservation(obs, deps) {
325
323
  askAfter: false,
326
324
  ...(correctionPath ? { correctionPath } : {}),
327
325
  },
328
- }, prior), deps.root);
326
+ }, prior, { preserveBindingEpisode: false }), deps.root);
329
327
  return {
330
328
  result: resultOf({
331
329
  kind: "company_conflict",
@@ -491,6 +489,69 @@ export async function reconcileObservation(obs, deps) {
491
489
  prior.bindingEpisodeId
492
490
  ? "needs_project"
493
491
  : registerClassification;
492
+ // Re-observing an already acknowledged binding is normal for hooks and the
493
+ // resident daemon. A fresh clientOperationId must not turn that observation
494
+ // into a second semantic register operation: the prior receipt is the stable
495
+ // identity for this exact company/project/task binding.
496
+ const acknowledgedPrior = prior?.bindingEpisodeId &&
497
+ prior.companyUid === resolvedCompany.uid &&
498
+ prior.companySlug === resolvedCompany.slug &&
499
+ prior.projectId === projectId &&
500
+ prior.taskId === taskId &&
501
+ listOutboxOperations(deps.root).some((operation) => operation.kind === "register" &&
502
+ operation.delivery === "acked" &&
503
+ operation.sessionId === sessionId &&
504
+ operation.receiptId === prior.bindingEpisodeId &&
505
+ operation.companyUid === resolvedCompany.uid &&
506
+ operation.companySlug === resolvedCompany.slug &&
507
+ operation.projectId === projectId &&
508
+ operation.taskId === taskId)
509
+ ? prior
510
+ : null;
511
+ if (acknowledgedPrior) {
512
+ // Activity is local progress, not binding identity. Retain the receipt
513
+ // without another register while carrying hook counters forward so the
514
+ // normal clarification threshold still advances.
515
+ writeSessionState(mergeLocalSessionFields({
516
+ contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
517
+ sessionId,
518
+ companyUid: acknowledgedPrior.companyUid,
519
+ companySlug: acknowledgedPrior.companySlug,
520
+ contextStatus: stateClassification,
521
+ projectId,
522
+ taskId,
523
+ bindingEpisodeId: acknowledgedPrior.bindingEpisodeId,
524
+ updatedAt: nowIso,
525
+ startedAt,
526
+ ...(typeof toolWrites === "number" ? { toolWrites } : {}),
527
+ ...(hasTurn ? { hasTurn: true } : {}),
528
+ ...(decision ? { decision } : {}),
529
+ ...transcriptFields,
530
+ }, prior), deps.root);
531
+ const kind = registerClassification === "bound"
532
+ ? "bound"
533
+ : registerClassification === "needs_task"
534
+ ? "needs_task"
535
+ : registerClassification === "needs_project"
536
+ ? "needs_project"
537
+ : "queued";
538
+ diagnostics.push(`binding unchanged; retained receipt=${acknowledgedPrior.bindingEpisodeId}`);
539
+ return {
540
+ result: resultOf({
541
+ kind,
542
+ classification: registerClassification,
543
+ delivery: "acked",
544
+ sessionId,
545
+ clientOperationId: obs.clientOperationId,
546
+ companyUid: resolvedCompany.uid,
547
+ projectId,
548
+ taskId,
549
+ bindingEpisodeId: acknowledgedPrior.bindingEpisodeId,
550
+ }),
551
+ exitCode: EXIT_OK,
552
+ diagnostics,
553
+ };
554
+ }
494
555
  let outboxOp;
495
556
  try {
496
557
  outboxOp = enqueueOutbox({
@@ -112,10 +112,12 @@ export declare function writeSessionState(state: SessionStateFile, root: string)
112
112
  /** Strip any non-allowlisted fields before durable write. */
113
113
  export declare function projectSessionState(state: SessionStateFile): SessionStateFile;
114
114
  /**
115
- * Merge prior local-only counters into a new state write so PostToolUse bumps
116
- * and reconcile updates do not clobber each other.
115
+ * Merge prior local fields into a new state write so PostToolUse bumps and
116
+ * reconcile updates do not clobber an acknowledged company binding.
117
117
  */
118
- export declare function mergeLocalSessionFields(next: SessionStateFile, prior: SessionStateFile | null): SessionStateFile;
118
+ export declare function mergeLocalSessionFields(next: SessionStateFile, prior: SessionStateFile | null, options?: {
119
+ preserveBindingEpisode?: boolean;
120
+ }): SessionStateFile;
119
121
  /**
120
122
  * Upgrade the local projection after the server acknowledges a register.
121
123
  *
@@ -103,13 +103,30 @@ export function projectSessionState(state) {
103
103
  return out;
104
104
  }
105
105
  /**
106
- * Merge prior local-only counters into a new state write so PostToolUse bumps
107
- * and reconcile updates do not clobber each other.
106
+ * Merge prior local fields into a new state write so PostToolUse bumps and
107
+ * reconcile updates do not clobber an acknowledged company binding.
108
108
  */
109
- export function mergeLocalSessionFields(next, prior) {
109
+ export function mergeLocalSessionFields(next, prior, options = {}) {
110
110
  if (!prior)
111
111
  return next;
112
112
  const out = { ...next };
113
+ // Hook observations commonly carry only tool-write/session metadata. Those
114
+ // writes must not erase a company that an acknowledged register already
115
+ // projected locally; losing it causes held events and repeat registrations.
116
+ // A caller that carries either company field is intentionally changing the
117
+ // binding and remains authoritative.
118
+ if (!out.companyUid && !out.companySlug && (prior.companyUid || prior.companySlug)) {
119
+ out.companyUid = prior.companyUid;
120
+ out.companySlug = prior.companySlug;
121
+ out.contextStatus = prior.contextStatus;
122
+ if (!out.projectId && prior.projectId)
123
+ out.projectId = prior.projectId;
124
+ if (!out.taskId && prior.taskId)
125
+ out.taskId = prior.taskId;
126
+ if (out.version === undefined && prior.version !== undefined) {
127
+ out.version = prior.version;
128
+ }
129
+ }
113
130
  if (out.toolWrites === undefined && typeof prior.toolWrites === "number") {
114
131
  out.toolWrites = prior.toolWrites;
115
132
  }
@@ -131,7 +148,14 @@ export function mergeLocalSessionFields(next, prior) {
131
148
  if (!out.transcriptMarker && prior.transcriptMarker) {
132
149
  out.transcriptMarker = prior.transcriptMarker;
133
150
  }
134
- if (!out.bindingEpisodeId && prior.bindingEpisodeId) {
151
+ const sameBinding = prior.companyUid === out.companyUid &&
152
+ prior.companySlug === out.companySlug &&
153
+ prior.projectId === out.projectId &&
154
+ prior.taskId === out.taskId;
155
+ if (options.preserveBindingEpisode !== false &&
156
+ !out.bindingEpisodeId &&
157
+ prior.bindingEpisodeId &&
158
+ sameBinding) {
135
159
  out.bindingEpisodeId = prior.bindingEpisodeId;
136
160
  }
137
161
  return out;
@@ -1,4 +1,25 @@
1
1
  export declare const TELEMETRY_TIMEOUT_MS = 1200;
2
+ /**
3
+ * Exact stderr line printed once on the first signed-in TTY run that is not
4
+ * `--json`. Wording matches the event body: CLI version + timestamp only.
5
+ */
6
+ export declare const TELEMETRY_DISCLOSURE_LINE = "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.";
7
+ /**
8
+ * `HQ_TELEMETRY=0` (also `false` and `off`, case-insensitive) disables every
9
+ * CLI POST to `/v1/telemetry/events`. `HQ_TELEMETRY_TIMEOUT_MS` is unchanged.
10
+ */
11
+ export declare function isCliTelemetryDisabled(env?: NodeJS.ProcessEnv): boolean;
12
+ export declare function telemetryDisclosureMarkerPath(): string;
13
+ /**
14
+ * Print the one-line disclosure at most once per machine. Returns whether
15
+ * the notice has been shown (this run or a previous one). `--json` and
16
+ * non-TTY stdout skip the print and do not record the marker.
17
+ */
18
+ export declare function maybeDiscloseCliTelemetry(stderr?: {
19
+ write: (chunk: string) => unknown;
20
+ }, stdout?: {
21
+ isTTY?: boolean;
22
+ }, argv?: readonly string[]): boolean;
2
23
  /**
3
24
  * Emit the authenticated CLI session signal using an already-cached token.
4
25
  * This deliberately never refreshes a session or opens a browser.
@@ -1,8 +1,80 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
1
4
  import { CLI_VERSION } from "../cli-version.js";
2
5
  import { isExpiring, isMachineIdentity, loadCachedTokens, } from "./cognito-session.js";
3
6
  import { vaultApiFetch } from "./vault-api.js";
4
7
  export const TELEMETRY_TIMEOUT_MS = 1_200;
8
+ /**
9
+ * Exact stderr line printed once on the first signed-in TTY run that is not
10
+ * `--json`. Wording matches the event body: CLI version + timestamp only.
11
+ */
12
+ export const TELEMETRY_DISCLOSURE_LINE = "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.";
13
+ const TELEMETRY_DISABLED_VALUES = new Set(["0", "false", "off"]);
14
+ const DISCLOSURE_MARKER = "cli-telemetry-disclosed";
5
15
  let cliSessionStartedPromise;
16
+ /**
17
+ * `HQ_TELEMETRY=0` (also `false` and `off`, case-insensitive) disables every
18
+ * CLI POST to `/v1/telemetry/events`. `HQ_TELEMETRY_TIMEOUT_MS` is unchanged.
19
+ */
20
+ export function isCliTelemetryDisabled(env = process.env) {
21
+ const raw = env.HQ_TELEMETRY?.trim().toLowerCase();
22
+ if (!raw)
23
+ return false;
24
+ return TELEMETRY_DISABLED_VALUES.has(raw);
25
+ }
26
+ function hqConfigDir() {
27
+ const home = process.env.HOME?.trim() || os.homedir();
28
+ return path.join(home, ".hq");
29
+ }
30
+ export function telemetryDisclosureMarkerPath() {
31
+ return path.join(hqConfigDir(), DISCLOSURE_MARKER);
32
+ }
33
+ function wantsJsonOutput(argv = process.argv) {
34
+ return argv.includes("--json");
35
+ }
36
+ function canPrintDisclosure(stdout = process.stdout, argv = process.argv) {
37
+ return Boolean(stdout.isTTY) && !wantsJsonOutput(argv);
38
+ }
39
+ function hasShownDisclosure() {
40
+ try {
41
+ return fs.existsSync(telemetryDisclosureMarkerPath());
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ function recordDisclosureShown() {
48
+ try {
49
+ const dir = hqConfigDir();
50
+ // Do not create ~/.hq. Signed-in runs already have it (cached tokens).
51
+ if (!fs.existsSync(dir))
52
+ return;
53
+ fs.writeFileSync(telemetryDisclosureMarkerPath(), `${new Date().toISOString()}\n`);
54
+ }
55
+ catch {
56
+ // Marker write is best effort; a repeat print is better than crashing.
57
+ }
58
+ }
59
+ /**
60
+ * Print the one-line disclosure at most once per machine. Returns whether
61
+ * the notice has been shown (this run or a previous one). `--json` and
62
+ * non-TTY stdout skip the print and do not record the marker.
63
+ */
64
+ export function maybeDiscloseCliTelemetry(stderr = process.stderr, stdout = process.stdout, argv = process.argv) {
65
+ if (hasShownDisclosure())
66
+ return true;
67
+ if (!canPrintDisclosure(stdout, argv))
68
+ return false;
69
+ try {
70
+ stderr.write(`${TELEMETRY_DISCLOSURE_LINE}\n`);
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ recordDisclosureShown();
76
+ return true;
77
+ }
6
78
  /**
7
79
  * Emit the authenticated CLI session signal using an already-cached token.
8
80
  * This deliberately never refreshes a session or opens a browser.
@@ -13,12 +85,16 @@ export function emitCliSessionStarted(timeoutMs = TELEMETRY_TIMEOUT_MS) {
13
85
  }
14
86
  async function emitCachedCliSessionStarted(timeoutMs) {
15
87
  try {
88
+ if (isCliTelemetryDisabled())
89
+ return;
16
90
  const cached = loadCachedTokens();
17
91
  if (!cached || isExpiring(cached, 120))
18
92
  return;
19
93
  const token = isMachineIdentity() ? cached.idToken : cached.accessToken;
20
94
  if (!token)
21
95
  return;
96
+ const disclosed = maybeDiscloseCliTelemetry();
97
+ const consentBasis = disclosed ? "disclosed" : "no-consent";
22
98
  const controller = new AbortController();
23
99
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
24
100
  try {
@@ -33,7 +109,7 @@ async function emitCachedCliSessionStarted(timeoutMs) {
33
109
  app: "hq-cli",
34
110
  source: "cli",
35
111
  occurredAt: new Date().toISOString(),
36
- consentBasis: "no-consent",
112
+ consentBasis,
37
113
  schemaVersion: 1,
38
114
  properties: { version: CLI_VERSION },
39
115
  },
@@ -57,6 +57,7 @@
57
57
  * version-check), `hq rescue --no-self-update`, and the re-exec guard env.
58
58
  */
59
59
  import { type RunningInstall, type UpdateResult } from "./version-gate.js";
60
+ import { type RestartDaemonResult } from "../lib/mesh/live/daemon/install.js";
60
61
  /**
61
62
  * Set on the re-exec'd child so it can never self-update (and re-exec) again.
62
63
  * One update + one re-exec per user invocation, ever.
@@ -153,6 +154,8 @@ export interface SelfUpdateDeps {
153
154
  * underneath themselves: hooks, scripts, agents, cron, CI.
154
155
  */
155
156
  interactive?: boolean;
157
+ /** Best-effort daemon kick after the CLI install converges (tests). */
158
+ restartDaemon?: () => RestartDaemonResult | void;
156
159
  }
157
160
  /**
158
161
  * Startup path: the running CLI is behind npm `latest`, so update in place and
@@ -63,6 +63,7 @@ import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
63
63
  import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, isLocalDependencyInstall, isPrefixWritable, nonWritablePrefixNote, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, } from "./version-gate.js";
64
64
  import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
65
65
  import { markLatestIneffective } from "./version-check.js";
66
+ import { restartDaemonServiceIfInstalled } from "../lib/mesh/live/daemon/install.js";
66
67
  /**
67
68
  * Set on the re-exec'd child so it can never self-update (and re-exec) again.
68
69
  * One update + one re-exec per user invocation, ever.
@@ -278,6 +279,16 @@ async function updateAndReexec(argv, flavor, known, deps) {
278
279
  console.error(chalk.dim(` Continuing the ${flavor.noun} on ${current}.`));
279
280
  return { action: "update-ineffective", latest };
280
281
  }
282
+ // A resident mesh daemon keeps the old JS loaded after a global install.
283
+ // Service restart is best-effort and cannot turn a successful update red.
284
+ try {
285
+ const restart = (deps.restartDaemon ?? restartDaemonServiceIfInstalled)();
286
+ if (restart?.message)
287
+ console.error(chalk.yellow(` ${restart.message}`));
288
+ }
289
+ catch {
290
+ // Defensive only; the default helper already absorbs manager failures.
291
+ }
281
292
  const childEnv = { ...env, [REEXEC_GUARD_ENV]: "1" };
282
293
  const status = (deps.reexec ?? reexecHq)([...argv.slice(2)], childEnv);
283
294
  if (status === null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.122.2",
3
+ "version": "5.123.1",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -13,7 +13,8 @@
13
13
  "assets/scaffold",
14
14
  "assets/bot-workers",
15
15
  "assets/mesh-daemon",
16
- "CHANGELOG.md"
16
+ "CHANGELOG.md",
17
+ "README.md"
17
18
  ],
18
19
  "scripts": {
19
20
  "build": "node scripts/generate-dsn.mjs && tsc && node scripts/generate-command-catalog.mjs --check && node scripts/copy-schemas.mjs && node scripts/chmod-bins.mjs",