@indigoai-us/hq-cli 5.132.0 → 5.134.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/commands/agent-kit.d.ts +2 -0
  3. package/dist/commands/agent-kit.js +4 -1
  4. package/dist/commands/lanes.d.ts +2 -2
  5. package/dist/commands/lanes.js +98 -20
  6. package/dist/commands/mesh.js +4 -1
  7. package/dist/lib/agent-kit/run/heartbeat.d.ts +14 -2
  8. package/dist/lib/agent-kit/run/heartbeat.js +38 -6
  9. package/dist/lib/bot/api.d.ts +3 -1
  10. package/dist/lib/bot/api.js +7 -2
  11. package/dist/lib/bot/run.d.ts +4 -0
  12. package/dist/lib/bot/run.js +30 -1
  13. package/dist/lib/bot/status.d.ts +4 -0
  14. package/dist/lib/doctor/checks/bots.js +4 -1
  15. package/dist/lib/heartbeat-identity.d.ts +29 -0
  16. package/dist/lib/heartbeat-identity.js +71 -0
  17. package/dist/lib/lanes/constants.d.ts +3 -0
  18. package/dist/lib/lanes/constants.js +3 -0
  19. package/dist/lib/lanes/index.d.ts +1 -0
  20. package/dist/lib/lanes/index.js +1 -0
  21. package/dist/lib/lanes/quota.d.ts +6 -3
  22. package/dist/lib/lanes/quota.js +65 -15
  23. package/dist/lib/lanes/reconcile.d.ts +5 -2
  24. package/dist/lib/lanes/reconcile.js +94 -2
  25. package/dist/lib/lanes/types.d.ts +2 -0
  26. package/dist/lib/lanes/types.js +2 -1
  27. package/dist/lib/mesh/live/daemon/doctor.d.ts +6 -0
  28. package/dist/lib/mesh/live/daemon/doctor.js +11 -0
  29. package/dist/lib/mesh/live/daemon/install.d.ts +7 -0
  30. package/dist/lib/mesh/live/daemon/install.js +16 -6
  31. package/dist/lib/mesh/live/daemon/legacy-listen.d.ts +43 -0
  32. package/dist/lib/mesh/live/daemon/legacy-listen.js +89 -0
  33. package/dist/lib/mesh/live/daemon/live-cache.d.ts +26 -0
  34. package/dist/lib/mesh/live/daemon/live-cache.js +116 -0
  35. package/dist/lib/mesh/live/daemon/presence.d.ts +7 -3
  36. package/dist/lib/mesh/live/daemon/presence.js +10 -9
  37. package/dist/lib/mesh/live/daemon/run.d.ts +13 -0
  38. package/dist/lib/mesh/live/daemon/run.js +51 -2
  39. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +31 -0
  40. package/dist/lib/mesh/live/daemon/work-subscribe.js +124 -0
  41. package/package.json +2 -2
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Retire the pack-era work-mesh listen unit so only `hq mesh daemon` subscribes.
3
+ *
4
+ * macOS: ~/Library/LaunchAgents/ai.getindigo.hq-work-mesh-listen.plist
5
+ * Linux: ~/.config/systemd/user/hq-work-mesh-listen.service
6
+ * Windows: Scheduled Task ai.getindigo.hq-work-mesh-listen
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { spawnSync } from "node:child_process";
11
+ export const LEGACY_LISTEN_LABEL = "ai.getindigo.hq-work-mesh-listen";
12
+ export const LEGACY_LISTEN_SYSTEMD_UNIT = "hq-work-mesh-listen.service";
13
+ export const LEGACY_LISTEN_TASK = LEGACY_LISTEN_LABEL;
14
+ export const LEGACY_LISTENER_DOCTOR_LINE = "legacy listener still installed";
15
+ export function legacyListenPlistPath(home) {
16
+ return path.join(home, "Library", "LaunchAgents", `${LEGACY_LISTEN_LABEL}.plist`);
17
+ }
18
+ export function legacyListenSystemdPath(home) {
19
+ return path.join(home, ".config", "systemd", "user", LEGACY_LISTEN_SYSTEMD_UNIT);
20
+ }
21
+ const defaultRunner = (cmd, args) => {
22
+ const r = spawnSync(cmd, args, { encoding: "utf8" });
23
+ return {
24
+ status: r.status,
25
+ stdout: r.stdout ?? "",
26
+ stderr: r.error ? r.error.message : (r.stderr ?? ""),
27
+ };
28
+ };
29
+ export function legacyListenInstalled(opts) {
30
+ const exists = opts.existsSync ?? fs.existsSync;
31
+ if (opts.platform === "darwin")
32
+ return exists(legacyListenPlistPath(opts.home));
33
+ if (opts.platform === "linux")
34
+ return exists(legacyListenSystemdPath(opts.home));
35
+ if (opts.platform === "win32")
36
+ return false;
37
+ return false;
38
+ }
39
+ /**
40
+ * Unload and delete the legacy listen unit when it is present.
41
+ * On-disk unit files are removed on every host. launchctl, systemctl, and
42
+ * schtasks run only for the matching platform, and only through `run` when
43
+ * the caller injected one (tests never hit a real service manager).
44
+ * Missing unit is a no-op.
45
+ */
46
+ export function retireLegacyListen(opts) {
47
+ const run = opts.run ?? (opts.platform === "other" ? undefined : defaultRunner);
48
+ let removed = false;
49
+ const plist = legacyListenPlistPath(opts.home);
50
+ if (fs.existsSync(plist)) {
51
+ if (opts.platform === "darwin" && run) {
52
+ const uid = opts.uid ?? (typeof process.getuid === "function" ? process.getuid() : undefined);
53
+ if (uid !== undefined) {
54
+ run("launchctl", ["bootout", `gui/${uid}/${LEGACY_LISTEN_LABEL}`]);
55
+ }
56
+ run("launchctl", ["unload", plist]);
57
+ }
58
+ try {
59
+ fs.unlinkSync(plist);
60
+ removed = true;
61
+ }
62
+ catch {
63
+ // leave removed false for this file
64
+ }
65
+ }
66
+ const unitPath = legacyListenSystemdPath(opts.home);
67
+ if (fs.existsSync(unitPath)) {
68
+ if (opts.platform === "linux" && run) {
69
+ run("systemctl", ["--user", "disable", "--now", LEGACY_LISTEN_SYSTEMD_UNIT]);
70
+ }
71
+ try {
72
+ fs.unlinkSync(unitPath);
73
+ removed = true;
74
+ if (opts.platform === "linux" && run) {
75
+ run("systemctl", ["--user", "daemon-reload"]);
76
+ }
77
+ }
78
+ catch {
79
+ // leave prior removed state
80
+ }
81
+ }
82
+ if (opts.platform === "win32" && opts.windowsTaskPresent && run) {
83
+ const result = run("schtasks.exe", ["/Delete", "/TN", LEGACY_LISTEN_TASK, "/F"]);
84
+ if (result.status === 0)
85
+ removed = true;
86
+ }
87
+ return { removed };
88
+ }
89
+ //# sourceMappingURL=legacy-listen.js.map
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Daemon-owned work-mesh live cache.
3
+ *
4
+ * ~/.hq/work-mesh/live-cache.json plus live-cache.json.cursor-v2.json.
5
+ * A project-view push updates the project row and bumps the cursor once.
6
+ * The same company/project/boardVersion (or eventId) does not bump again.
7
+ */
8
+ export declare function liveCacheFile(meshRoot: string): string;
9
+ export declare function liveCacheCursorFile(meshRoot: string): string;
10
+ export interface ProjectViewPush {
11
+ companyUid: string;
12
+ projectId: string;
13
+ boardVersion?: string;
14
+ eventId?: string;
15
+ }
16
+ /** Parse a board wake. Returns null for every other payload. */
17
+ export declare function parseProjectViewPush(payload: unknown): ProjectViewPush | null;
18
+ /**
19
+ * Apply one project-view push. Cursor increases by exactly one on first sight
20
+ * of the key, and is left unchanged when the same push is applied again.
21
+ */
22
+ export declare function applyProjectViewPush(meshRoot: string, push: ProjectViewPush, nowIso?: string): {
23
+ applied: boolean;
24
+ cursor: number;
25
+ };
26
+ //# sourceMappingURL=live-cache.d.ts.map
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Daemon-owned work-mesh live cache.
3
+ *
4
+ * ~/.hq/work-mesh/live-cache.json plus live-cache.json.cursor-v2.json.
5
+ * A project-view push updates the project row and bumps the cursor once.
6
+ * The same company/project/boardVersion (or eventId) does not bump again.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ export function liveCacheFile(meshRoot) {
11
+ return path.join(meshRoot, "live-cache.json");
12
+ }
13
+ export function liveCacheCursorFile(meshRoot) {
14
+ return path.join(meshRoot, "live-cache.json.cursor-v2.json");
15
+ }
16
+ function readJson(file) {
17
+ try {
18
+ return JSON.parse(fs.readFileSync(file, "utf8"));
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ function writeJson(file, value) {
25
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
26
+ const tmp = `${file}.tmp.${process.pid}`;
27
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
28
+ fs.renameSync(tmp, file);
29
+ }
30
+ function pushKey(push) {
31
+ if (push.eventId && push.eventId.trim())
32
+ return `id:${push.eventId.trim()}`;
33
+ return `pv:${push.companyUid}:${push.projectId}:${push.boardVersion ?? ""}`;
34
+ }
35
+ /** Parse a board wake. Returns null for every other payload. */
36
+ export function parseProjectViewPush(payload) {
37
+ let value = payload;
38
+ if (Buffer.isBuffer(value))
39
+ value = value.toString("utf8");
40
+ if (typeof value === "string") {
41
+ try {
42
+ value = JSON.parse(value);
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ if (!value || typeof value !== "object" || Array.isArray(value))
49
+ return null;
50
+ const rec = value;
51
+ if (rec.kind !== "board" || rec.mutation !== "project-view")
52
+ return null;
53
+ if (typeof rec.companyUid !== "string" || typeof rec.projectId !== "string")
54
+ return null;
55
+ if (!rec.companyUid || !rec.projectId)
56
+ return null;
57
+ return {
58
+ companyUid: rec.companyUid,
59
+ projectId: rec.projectId,
60
+ boardVersion: typeof rec.boardVersion === "string" ? rec.boardVersion : undefined,
61
+ eventId: typeof rec.eventId === "string" ? rec.eventId : undefined,
62
+ };
63
+ }
64
+ /**
65
+ * Apply one project-view push. Cursor increases by exactly one on first sight
66
+ * of the key, and is left unchanged when the same push is applied again.
67
+ */
68
+ export function applyProjectViewPush(meshRoot, push, nowIso = new Date().toISOString()) {
69
+ const key = pushKey(push);
70
+ const stored = readJson(liveCacheCursorFile(meshRoot));
71
+ const previous = stored &&
72
+ typeof stored === "object" &&
73
+ !Array.isArray(stored) &&
74
+ stored.version === 1 &&
75
+ typeof stored.cursor === "number"
76
+ ? {
77
+ version: 1,
78
+ cursor: stored.cursor,
79
+ seen: Array.isArray(stored.seen)
80
+ ? stored.seen.filter((s) => typeof s === "string").slice(-200)
81
+ : [],
82
+ }
83
+ : { version: 1, cursor: 0, seen: [] };
84
+ if (previous.seen.includes(key)) {
85
+ return { applied: false, cursor: previous.cursor };
86
+ }
87
+ const cursor = previous.cursor + 1;
88
+ const cacheRaw = readJson(liveCacheFile(meshRoot));
89
+ const cache = cacheRaw && typeof cacheRaw === "object" && !Array.isArray(cacheRaw)
90
+ ? cacheRaw
91
+ : {};
92
+ const projects = cache.projects && typeof cache.projects === "object" && !Array.isArray(cache.projects)
93
+ ? { ...cache.projects }
94
+ : {};
95
+ const rowKey = `${push.companyUid}/${push.projectId}`;
96
+ projects[rowKey] = {
97
+ companyUid: push.companyUid,
98
+ projectId: push.projectId,
99
+ mutation: "project-view",
100
+ boardVersion: push.boardVersion,
101
+ updatedAt: nowIso,
102
+ };
103
+ writeJson(liveCacheFile(meshRoot), {
104
+ ...cache,
105
+ updatedAt: nowIso,
106
+ cursor,
107
+ projects,
108
+ });
109
+ writeJson(liveCacheCursorFile(meshRoot), {
110
+ version: 1,
111
+ cursor,
112
+ seen: [...previous.seen, key].slice(-200),
113
+ });
114
+ return { applied: true, cursor };
115
+ }
116
+ //# sourceMappingURL=live-cache.js.map
@@ -34,7 +34,11 @@ export declare const CONNECT_STABLE_GRACE_MS = 5000;
34
34
  * waits at least this long regardless of the backoff/grace-reset state.
35
35
  */
36
36
  export declare const RECONNECT_MIN_DELAY_MS = 1000;
37
- /** Always emit the first N close info lines, then at most one per interval. */
37
+ /**
38
+ * Close/reconnect info is one line per transition into reconnecting.
39
+ * Repeated `close` events while already reconnecting do not log again.
40
+ * Kept so older imports still resolve; they no longer gate the log.
41
+ */
38
42
  export declare const CLOSE_LOG_ALWAYS_COUNT = 3;
39
43
  export declare const CLOSE_LOG_INTERVAL_MS = 60000;
40
44
  export interface PresencePayload {
@@ -111,8 +115,8 @@ export declare class PresenceClient {
111
115
  private reconnectHandle;
112
116
  private stableHandle;
113
117
  private connectedAtMs;
114
- private closeLogCount;
115
- private lastCloseLogAtMs;
118
+ /** One close log per transition into reconnecting, until the next connect. */
119
+ private reconnectLogged;
116
120
  private stopped;
117
121
  private generation;
118
122
  private publishedTopics;
@@ -34,7 +34,11 @@ export const CONNECT_STABLE_GRACE_MS = 5_000;
34
34
  * waits at least this long regardless of the backoff/grace-reset state.
35
35
  */
36
36
  export const RECONNECT_MIN_DELAY_MS = 1_000;
37
- /** Always emit the first N close info lines, then at most one per interval. */
37
+ /**
38
+ * Close/reconnect info is one line per transition into reconnecting.
39
+ * Repeated `close` events while already reconnecting do not log again.
40
+ * Kept so older imports still resolve; they no longer gate the log.
41
+ */
38
42
  export const CLOSE_LOG_ALWAYS_COUNT = 3;
39
43
  export const CLOSE_LOG_INTERVAL_MS = 60_000;
40
44
  /**
@@ -81,8 +85,8 @@ export class PresenceClient {
81
85
  reconnectHandle = null;
82
86
  stableHandle = null;
83
87
  connectedAtMs = null;
84
- closeLogCount = 0;
85
- lastCloseLogAtMs = Number.NEGATIVE_INFINITY;
88
+ /** One close log per transition into reconnecting, until the next connect. */
89
+ reconnectLogged = false;
86
90
  stopped = false;
87
91
  generation = 0;
88
92
  publishedTopics = [];
@@ -281,6 +285,7 @@ export class PresenceClient {
281
285
  this.attempt = 0;
282
286
  this.lastBackoffMs = 0;
283
287
  }, CONNECT_STABLE_GRACE_MS);
288
+ this.reconnectLogged = false;
284
289
  this.setState("connected");
285
290
  void this.publishAll("online").then(() => {
286
291
  this.options.onOnline?.(bundle.companies.map((c) => c.companyUid));
@@ -383,13 +388,9 @@ export class PresenceClient {
383
388
  return delay;
384
389
  }
385
390
  maybeLogClose(elapsedMs, delayMs) {
386
- const now = this.timers.now();
387
- const withinAlways = this.closeLogCount < CLOSE_LOG_ALWAYS_COUNT;
388
- const intervalElapsed = now - this.lastCloseLogAtMs >= CLOSE_LOG_INTERVAL_MS;
389
- if (!withinAlways && !intervalElapsed)
391
+ if (this.reconnectLogged)
390
392
  return;
391
- this.closeLogCount += 1;
392
- this.lastCloseLogAtMs = now;
393
+ this.reconnectLogged = true;
393
394
  this.options.onInfo?.(`presence mqtt closed after ${elapsedMs}ms; reconnect in ${delayMs}ms`);
394
395
  }
395
396
  clearReconnect() {
@@ -18,6 +18,7 @@ import { type PidLockDeps } from "./pid-lock.js";
18
18
  import { PresenceClient, type MqttConnectFn } from "./presence.js";
19
19
  import { TranscriptWatcher, type TranscriptFs } from "./transcript-watch.js";
20
20
  import { type MeshEmitMode } from "./mode.js";
21
+ import { type CommandRunner, type LegacyListenPlatform } from "./legacy-listen.js";
21
22
  export declare const SPOOL_DEBOUNCE_MS = 2000;
22
23
  export declare const FLUSH_INTERVAL_MS = 10000;
23
24
  /** Keep startup recovery bounded: the next daemon start continues the backlog. */
@@ -102,10 +103,22 @@ export interface DaemonRunDeps {
102
103
  runForMs?: number;
103
104
  /** CLI version loaded by this daemon. Defaults to this package's version. */
104
105
  daemonVersion?: string;
106
+ /**
107
+ * Subscribe to the personal work topic and write live-cache.json.
108
+ * The `hq mesh daemon run` command turns this on. Tests leave it off
109
+ * unless they inject credentials.
110
+ */
111
+ enableWorkCache?: boolean;
112
+ fetchPersonalCredentials?: import("./credentials.js").PersonalCredentialsFetcher;
105
113
  /** Read the currently installed CLI version behind the launch entrypoint. */
106
114
  readInstalledCliVersion?: () => string | null;
107
115
  random?: () => number;
108
116
  now?: () => Date;
117
+ /** Host for legacy listen retirement. Tests inject this; default is process.platform. */
118
+ legacyListenPlatform?: LegacyListenPlatform;
119
+ /** Service-manager runner for legacy listen retirement (tests inject). */
120
+ legacyListenRun?: CommandRunner;
121
+ windowsLegacyTaskPresent?: boolean;
109
122
  }
110
123
  export interface DaemonHandle {
111
124
  stop: () => Promise<void>;
@@ -26,17 +26,19 @@ import { backfillHeldSessions, } from "../backfill-held.js";
26
26
  import { reconcileObservation } from "../../../work-context/reconcile.js";
27
27
  import { workMeshRoot, workMeshSpoolPath } from "../paths.js";
28
28
  import { createVaultBoardReader, refreshBoundSessionBoards, BOARD_REFRESH_INTERVAL_MS, } from "./board-refresh.js";
29
- import { createContract3Fetcher, realTimerHost, } from "./credentials.js";
29
+ import { createContract3Fetcher, createPersonalRealtimeFetcher, realTimerHost, } from "./credentials.js";
30
30
  import { appendDaemonLog, daemonLogLine, ensureDaemonLog } from "./log.js";
31
31
  import { acquirePidLock, defaultPidLockDeps, releasePidLock, } from "./pid-lock.js";
32
32
  import { daemonDir } from "./paths.js";
33
- import { PresenceClient, } from "./presence.js";
33
+ import { PresenceClient, defaultMqttConnect, } from "./presence.js";
34
34
  import { defaultDaemonState, patchDaemonState, readDaemonState, writeDaemonState, } from "./state.js";
35
35
  import { workMeshHeldPath } from "../paths.js";
36
36
  import { noteHookSessionsFromSpoolFile, TRANSCRIPT_WATCH_INTERVAL_MS, TranscriptWatcher, } from "./transcript-watch.js";
37
37
  import { resolveMeshEmitMode, runsEmitPath, } from "./mode.js";
38
38
  import { hasCliVersionDrift, readInstalledCliVersion } from "./version-drift.js";
39
39
  import { readDaemonConfig } from "./config.js";
40
+ import { retireLegacyListen } from "./legacy-listen.js";
41
+ import { startWorkCacheSubscriber } from "./work-subscribe.js";
40
42
  export const SPOOL_DEBOUNCE_MS = 2_000;
41
43
  export const FLUSH_INTERVAL_MS = 10_000;
42
44
  /** Keep startup recovery bounded: the next daemon start continues the backlog. */
@@ -112,6 +114,15 @@ export async function runMeshDaemon(deps = {}) {
112
114
  const daemonVersion = deps.daemonVersion ?? CLI_VERSION;
113
115
  const installedCliVersion = deps.readInstalledCliVersion ?? readInstalledCliVersion;
114
116
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
117
+ const hostPlatform = process.platform === "darwin" || process.platform === "linux" || process.platform === "win32"
118
+ ? process.platform
119
+ : "other";
120
+ const retiredListen = retireLegacyListen({
121
+ home,
122
+ platform: deps.legacyListenPlatform ?? hostPlatform,
123
+ run: deps.legacyListenRun,
124
+ windowsTaskPresent: deps.windowsLegacyTaskPresent,
125
+ });
115
126
  const configuredRoots = readDaemonConfig(dir).trustedHqRoots;
116
127
  const trustedRoots = [...new Set([
117
128
  ...(deps.hqRoot ? [deps.hqRoot] : []),
@@ -119,6 +130,9 @@ export async function runMeshDaemon(deps = {}) {
119
130
  ...configuredRoots,
120
131
  ])];
121
132
  ensureDaemonLog(dir);
133
+ if (retiredListen.removed) {
134
+ log(dir, "retired legacy listener ai.getindigo.hq-work-mesh-listen");
135
+ }
122
136
  const lockDeps = deps.pidLock ?? defaultPidLockDeps({ isProcessAlive: alive });
123
137
  const lock = acquirePidLock(dir, lockDeps);
124
138
  if (!lock.ok) {
@@ -369,6 +383,7 @@ export async function runMeshDaemon(deps = {}) {
369
383
  });
370
384
  });
371
385
  let presence = null;
386
+ let workCache = null;
372
387
  if (deps.enablePresence !== false) {
373
388
  const fetchCredentialsRaw = deps.fetchCredentials ??
374
389
  (async () => {
@@ -430,6 +445,31 @@ export async function runMeshDaemon(deps = {}) {
430
445
  // Continue draining spool even if MQTT is down.
431
446
  }
432
447
  }
448
+ if (deps.enableWorkCache) {
449
+ try {
450
+ workCache = await startWorkCacheSubscriber({
451
+ meshRoot,
452
+ fetchCredentials: deps.fetchPersonalCredentials ?? (async () => {
453
+ const t = await getToken();
454
+ return createPersonalRealtimeFetcher({
455
+ token: t,
456
+ baseUrl: resolveVaultApiBase(env),
457
+ })();
458
+ }),
459
+ mqttConnect: (url, options) => {
460
+ const connect = deps.mqttConnect ?? defaultMqttConnect;
461
+ return connect(url, options);
462
+ },
463
+ now,
464
+ onInfo: (message) => log(dir, message.slice(0, 200)),
465
+ random: deps.random,
466
+ });
467
+ }
468
+ catch (err) {
469
+ const msg = err instanceof Error ? err.message : String(err);
470
+ log(dir, `work cache subscribe failed: ${msg.slice(0, 200)}`);
471
+ }
472
+ }
433
473
  let stopped = false;
434
474
  let restartRequested = false;
435
475
  let flushInFlight = null;
@@ -708,6 +748,15 @@ export async function runMeshDaemon(deps = {}) {
708
748
  // Final flush, then clean MQTT DISCONNECT so the broker emits the
709
749
  // lifecycle event; offline is published server-side only.
710
750
  await doFlushCycle("shutdown", { force: true });
751
+ if (workCache) {
752
+ try {
753
+ await workCache.stop();
754
+ }
755
+ catch (err) {
756
+ const msg = err instanceof Error ? err.message : String(err);
757
+ log(dir, `work cache stop error: ${msg.slice(0, 120)}`);
758
+ }
759
+ }
711
760
  if (presence) {
712
761
  try {
713
762
  await presence.stop();
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Contract-2 subscriber for the personal work topic.
3
+ * Owns live-cache.json writes. Presence publish stays on PresenceClient.
4
+ */
5
+ import type { IClientOptions } from "mqtt";
6
+ import { type PersonalCredentialsFetcher } from "./credentials.js";
7
+ export interface WorkCacheMqtt {
8
+ on(event: string, handler: (...args: any[]) => void): unknown;
9
+ subscribe(topic: string, opts: {
10
+ qos: 0 | 1;
11
+ }, cb?: (err?: Error | null) => void): unknown;
12
+ end(force?: boolean, cb?: () => void): unknown;
13
+ }
14
+ export interface WorkCacheSubscriber {
15
+ stop: () => Promise<void>;
16
+ }
17
+ export declare function startWorkCacheSubscriber(opts: {
18
+ meshRoot: string;
19
+ fetchCredentials?: PersonalCredentialsFetcher;
20
+ token?: () => Promise<string>;
21
+ baseUrl?: string;
22
+ mqttConnect: (url: string, options: IClientOptions) => WorkCacheMqtt;
23
+ now?: () => Date;
24
+ timers?: {
25
+ setTimeout: (fn: () => void, ms: number) => unknown;
26
+ clearTimeout: (handle: unknown) => void;
27
+ };
28
+ onInfo?: (message: string) => void;
29
+ random?: () => number;
30
+ }): Promise<WorkCacheSubscriber>;
31
+ //# sourceMappingURL=work-subscribe.d.ts.map
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Contract-2 subscriber for the personal work topic.
3
+ * Owns live-cache.json writes. Presence publish stays on PresenceClient.
4
+ */
5
+ import { createPersonalRealtimeFetcher, } from "./credentials.js";
6
+ import { presignIotWssUrl } from "./presign.js";
7
+ import { applyProjectViewPush, parseProjectViewPush } from "./live-cache.js";
8
+ import { backoffDelayMs, RECONNECT_MIN_DELAY_MS } from "./presence.js";
9
+ export async function startWorkCacheSubscriber(opts) {
10
+ const fetchCredentials = opts.fetchCredentials ??
11
+ (opts.token
12
+ ? createPersonalRealtimeFetcher({
13
+ token: await opts.token(),
14
+ baseUrl: opts.baseUrl,
15
+ })
16
+ : null);
17
+ if (!fetchCredentials) {
18
+ throw new Error("work cache subscriber needs credentials");
19
+ }
20
+ const now = opts.now ?? (() => new Date());
21
+ const timers = opts.timers ?? {
22
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
23
+ clearTimeout: (handle) => clearTimeout(handle),
24
+ };
25
+ const random = opts.random ?? Math.random;
26
+ let stopped = false;
27
+ let client = null;
28
+ let reconnectLogged = false;
29
+ let attempt = 0;
30
+ let timer = null;
31
+ let generation = 0;
32
+ const connect = async () => {
33
+ if (stopped)
34
+ return;
35
+ const gen = ++generation;
36
+ let bundle;
37
+ try {
38
+ bundle = await fetchCredentials();
39
+ }
40
+ catch (err) {
41
+ schedule(err);
42
+ return;
43
+ }
44
+ if (stopped || gen !== generation)
45
+ return;
46
+ const topic = bundle.topics.work;
47
+ if (!topic) {
48
+ opts.onInfo?.("work cache subscribe skipped: credentials have no work topic");
49
+ return;
50
+ }
51
+ const url = presignIotWssUrl(bundle.credentials, bundle.iotEndpoint, bundle.region, now());
52
+ const next = opts.mqttConnect(url, {
53
+ clientId: `${bundle.clientId}-work`,
54
+ clean: true,
55
+ reconnectPeriod: 0,
56
+ protocolVersion: 4,
57
+ });
58
+ client = next;
59
+ next.on("connect", () => {
60
+ if (stopped || gen !== generation)
61
+ return;
62
+ reconnectLogged = false;
63
+ attempt = 0;
64
+ next.subscribe(topic, { qos: 1 }, () => undefined);
65
+ });
66
+ next.on("message", (_topic, payload) => {
67
+ if (stopped || gen !== generation)
68
+ return;
69
+ if (_topic !== topic)
70
+ return;
71
+ const push = parseProjectViewPush(payload);
72
+ if (!push)
73
+ return;
74
+ const result = applyProjectViewPush(opts.meshRoot, push, now().toISOString());
75
+ if (result.applied) {
76
+ opts.onInfo?.(`project-view cache cursor=${result.cursor} ${push.companyUid}/${push.projectId}`);
77
+ }
78
+ });
79
+ next.on("close", () => {
80
+ if (stopped || gen !== generation)
81
+ return;
82
+ schedule();
83
+ });
84
+ };
85
+ const schedule = (err) => {
86
+ if (stopped)
87
+ return;
88
+ if (!reconnectLogged) {
89
+ reconnectLogged = true;
90
+ const detail = err instanceof Error ? `: ${err.message}` : "";
91
+ opts.onInfo?.(`work cache mqtt reconnecting${detail}`.slice(0, 200));
92
+ }
93
+ if (timer !== null)
94
+ timers.clearTimeout(timer);
95
+ const delay = backoffDelayMs(attempt, 1000, 60_000, random, 0, RECONNECT_MIN_DELAY_MS);
96
+ attempt += 1;
97
+ timer = timers.setTimeout(() => {
98
+ timer = null;
99
+ void connect();
100
+ }, delay);
101
+ };
102
+ await connect();
103
+ return {
104
+ stop: async () => {
105
+ stopped = true;
106
+ generation += 1;
107
+ if (timer !== null)
108
+ timers.clearTimeout(timer);
109
+ const current = client;
110
+ client = null;
111
+ if (!current)
112
+ return;
113
+ await new Promise((resolve) => {
114
+ try {
115
+ current.end(true, () => resolve());
116
+ }
117
+ catch {
118
+ resolve();
119
+ }
120
+ });
121
+ },
122
+ };
123
+ }
124
+ //# sourceMappingURL=work-subscribe.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.132.0",
3
+ "version": "5.134.0",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
41
41
  "@aws-sdk/client-s3": "^3.1049.0",
42
- "@indigoai-us/hq-cloud": "~6.16.60",
42
+ "@indigoai-us/hq-cloud": "~6.16.74",
43
43
  "@indigoai-us/hq-flags-client": "^0.1.2",
44
44
  "@sentry/node": "^10.49.0",
45
45
  "@tobilu/qmd": "2.5.3",