@indigoai-us/hq-cli 5.132.0 → 5.133.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.
@@ -198,7 +198,8 @@ export const LANE_FIELD_WRITERS = {
198
198
  project_id: ["command"], story_id: ["command"], thread_id: ["creator", "command"],
199
199
  depth: ["command"], senior: ["command"], senior_history: ["command"],
200
200
  state: ["creator", "command", "watcher", "reconcile", "spawn"], queued_reason: ["creator", "command", "spawn", "reconcile"],
201
- pickup_validation_pending: ["creator", "command", "spawn"],
201
+ pickup_validation_pending: ["creator", "command", "spawn", "reconcile"],
202
+ pickup_validation_pending_since: ["creator", "command", "spawn", "reconcile"],
202
203
  priority: ["command"], worker: ["creator"], worker_path: ["creator"], worker_pack: ["creator"],
203
204
  provider: ["creator"], account: ["creator", "spawn", "command"], account_history: ["command"], pending_resume: ["command", "spawn"], host: ["creator"], origin_host: ["creator"], origin_host_source: ["creator"],
204
205
  version: ["creator"], created_at: ["creator"], updated_at: ["creator"], attribution_history: ["command"],
@@ -5,6 +5,7 @@
5
5
  import { type CognitoActorKind, type CognitoTokenSource } from "../../../../utils/cognito-session.js";
6
6
  import { type UnattributedEventCounts } from "../spool.js";
7
7
  import { type DaemonStateFile } from "./state.js";
8
+ import { type LegacyListenPlatform } from "./legacy-listen.js";
8
9
  export declare const UNHEALTHY_SPOOL_AGE_MS = 60000;
9
10
  export interface DaemonDoctorReport {
10
11
  running: boolean;
@@ -54,6 +55,8 @@ export interface DaemonDoctorReport {
54
55
  };
55
56
  /** Work held or quarantined after default-derived membership was denied. */
56
57
  deviceDefaultAuthorizationDenials: number;
58
+ /** True when the pack-era listen unit is still on disk. */
59
+ legacyListenerInstalled: boolean;
57
60
  }
58
61
  export interface DaemonDoctorDeps {
59
62
  home?: string;
@@ -64,6 +67,9 @@ export interface DaemonDoctorDeps {
64
67
  env?: NodeJS.ProcessEnv;
65
68
  isProcessAlive?: (pid: number) => boolean;
66
69
  now?: () => number;
70
+ /** Override the legacy-listen probe (tests). */
71
+ legacyListenerInstalled?: boolean;
72
+ platform?: LegacyListenPlatform;
67
73
  }
68
74
  export declare function collectDaemonDoctor(deps?: DaemonDoctorDeps): DaemonDoctorReport;
69
75
  export declare function formatDaemonDoctor(report: DaemonDoctorReport): string[];
@@ -18,6 +18,7 @@ import { hasCurrentAcknowledgedRegistration, readHeldSessions, refWithLocalCompa
18
18
  import { readDaemonConfig } from "./config.js";
19
19
  import { pidLockStatus, readPidLock } from "./pid-lock.js";
20
20
  import { readDaemonState } from "./state.js";
21
+ import { LEGACY_LISTENER_DOCTOR_LINE, legacyListenInstalled, } from "./legacy-listen.js";
21
22
  export const UNHEALTHY_SPOOL_AGE_MS = 60_000;
22
23
  function oldestJsonlAgeMs(filePath, nowMs) {
23
24
  if (!fs.existsSync(filePath))
@@ -144,6 +145,14 @@ export function collectDaemonDoctor(deps = {}) {
144
145
  if (presenceRefusal) {
145
146
  unhealthyReasons.push(`presence credential refused (${presenceRefusal.code}); next retry ${presenceRefusal.nextRetryAt}`);
146
147
  }
148
+ const platform = deps.platform ?? process.platform;
149
+ const legacyListener = deps.legacyListenerInstalled ??
150
+ (platform === "darwin" || platform === "linux"
151
+ ? legacyListenInstalled({ home, platform })
152
+ : false);
153
+ if (legacyListener) {
154
+ unhealthyReasons.push(LEGACY_LISTENER_DOCTOR_LINE);
155
+ }
147
156
  const auth = describeCognitoTokenSource({ home, env });
148
157
  const whoamiTokens = readPersonTokenFile(home, env);
149
158
  const daemonTokens = auth.tokenSource === "machine"
@@ -192,6 +201,7 @@ export function collectDaemonDoctor(deps = {}) {
192
201
  }
193
202
  : undefined,
194
203
  deviceDefaultAuthorizationDenials,
204
+ legacyListenerInstalled: legacyListener,
195
205
  };
196
206
  }
197
207
  export function formatDaemonDoctor(report) {
@@ -200,6 +210,7 @@ export function formatDaemonDoctor(report) {
200
210
  `mode: ${report.emitMode}`,
201
211
  `mqtt: ${report.mqttState}`,
202
212
  `subscription: ${report.subscribed ? "connected" : "disconnected"}`,
213
+ ...(report.legacyListenerInstalled ? [LEGACY_LISTENER_DOCTOR_LINE] : []),
203
214
  `companies online: ${report.companiesOnline.length ? report.companiesOnline.join(", ") : "(none)"}`,
204
215
  ];
205
216
  if (report.emitMode === "legacy") {
@@ -235,6 +235,13 @@ export declare function installDaemonService(opts: {
235
235
  schtasks?: SchtasksRunner;
236
236
  /** Windows: terminate the recorded daemon pid when replacing a task (tests inject). */
237
237
  kill?: (pid: number) => void;
238
+ /** Service-manager runner for retiring the legacy listen unit (tests inject). */
239
+ runCommand?: (cmd: string, args: string[]) => {
240
+ status: number | null;
241
+ stdout: string;
242
+ stderr: string;
243
+ };
244
+ windowsLegacyTaskPresent?: boolean;
238
245
  }): InstallResult;
239
246
  export declare function uninstallDaemonService(opts: {
240
247
  platform?: DaemonInstallPlatform;
@@ -19,6 +19,7 @@ import { resolveDaemonAssetDir } from "./log.js";
19
19
  import { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, WINDOWS_TASK_NAME, daemonDir, daemonLogPath, daemonPidPath, } from "./paths.js";
20
20
  import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, verifyHqRoot } from "./config.js";
21
21
  import { defaultPidLockDeps, pidLockStatus, readPidLock } from "./pid-lock.js";
22
+ import { retireLegacyListen } from "./legacy-listen.js";
22
23
  /**
23
24
  * `schtasks /End` terminates the task's root process only. The daemon is
24
25
  * launched through `conhost.exe --headless`, so ending the task kills conhost
@@ -651,6 +652,15 @@ export function installDaemonService(opts) {
651
652
  const mkdirSync = opts.mkdirSync ?? fs.mkdirSync;
652
653
  mkdirSync(paths.daemonDir, { recursive: true, mode: 0o700 });
653
654
  registerTrustedHqRoot(paths.daemonDir, paths.hqRoot);
655
+ const retired = retireLegacyListen({
656
+ home: paths.home,
657
+ platform,
658
+ run: opts.runCommand,
659
+ windowsTaskPresent: opts.windowsLegacyTaskPresent,
660
+ });
661
+ const legacyNote = retired.removed
662
+ ? " Retired legacy listener ai.getindigo.hq-work-mesh-listen."
663
+ : "";
654
664
  if (platform === "darwin") {
655
665
  const rendered = renderLaunchdPlist(paths);
656
666
  mkdirSync(paths.launchAgentsDir, { recursive: true });
@@ -662,7 +672,7 @@ export function installDaemonService(opts) {
662
672
  paths,
663
673
  rendered,
664
674
  dest: paths.plistPath,
665
- message: `Installed LaunchAgent ${LAUNCHD_LABEL} → ${paths.plistPath}`,
675
+ message: `Installed LaunchAgent ${LAUNCHD_LABEL} → ${paths.plistPath}${legacyNote}`,
666
676
  };
667
677
  }
668
678
  if (platform === "linux") {
@@ -682,7 +692,7 @@ export function installDaemonService(opts) {
682
692
  dest,
683
693
  message: `Wrote system unit template for user ${opts.systemUser ?? paths.systemUser} at ${dest}. ` +
684
694
  `Install with: sudo cp ${dest} /etc/systemd/system/${SYSTEMD_UNIT_NAME} && ` +
685
- `sudo systemctl daemon-reload && sudo systemctl enable --now ${SYSTEMD_UNIT_NAME}`,
695
+ `sudo systemctl daemon-reload && sudo systemctl enable --now ${SYSTEMD_UNIT_NAME}${legacyNote}`,
686
696
  };
687
697
  }
688
698
  const rendered = renderSystemdUserUnit(paths);
@@ -696,7 +706,7 @@ export function installDaemonService(opts) {
696
706
  paths,
697
707
  rendered,
698
708
  dest: paths.systemdUnitPath,
699
- message: `Installed systemd user unit ${SYSTEMD_UNIT_NAME} → ${paths.systemdUnitPath}`,
709
+ message: `Installed systemd user unit ${SYSTEMD_UNIT_NAME} → ${paths.systemdUnitPath}${legacyNote}`,
700
710
  };
701
711
  }
702
712
  if (platform === "win32") {
@@ -728,9 +738,9 @@ export function installDaemonService(opts) {
728
738
  dest: paths.windowsTaskXmlPath,
729
739
  running,
730
740
  message: running
731
- ? `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (started; ${mode})`
741
+ ? `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (started; ${mode})${legacyNote}`
732
742
  : `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (${mode}); ` +
733
- `start failed: ${(started.stderr || started.stdout).trim()}. It starts at next logon.`,
743
+ `start failed: ${(started.stderr || started.stdout).trim()}. It starts at next logon.${legacyNote}`,
734
744
  notice: windowsLogonNotice(paths),
735
745
  };
736
746
  }
@@ -738,7 +748,7 @@ export function installDaemonService(opts) {
738
748
  platform,
739
749
  action: "install",
740
750
  paths,
741
- message: `Unsupported platform for service install; run: ${paths.nodeBinary} ${paths.hqBinary} mesh daemon run`,
751
+ message: `Unsupported platform for service install; run: ${paths.nodeBinary} ${paths.hqBinary} mesh daemon run${legacyNote}`,
742
752
  };
743
753
  }
744
754
  export function uninstallDaemonService(opts) {
@@ -0,0 +1,43 @@
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
+ export type LegacyListenPlatform = "darwin" | "linux" | "win32" | "other";
9
+ export declare const LEGACY_LISTEN_LABEL = "ai.getindigo.hq-work-mesh-listen";
10
+ export declare const LEGACY_LISTEN_SYSTEMD_UNIT = "hq-work-mesh-listen.service";
11
+ export declare const LEGACY_LISTEN_TASK = "ai.getindigo.hq-work-mesh-listen";
12
+ export declare const LEGACY_LISTENER_DOCTOR_LINE = "legacy listener still installed";
13
+ export interface CommandResult {
14
+ status: number | null;
15
+ stdout: string;
16
+ stderr: string;
17
+ }
18
+ export type CommandRunner = (cmd: string, args: string[]) => CommandResult;
19
+ export declare function legacyListenPlistPath(home: string): string;
20
+ export declare function legacyListenSystemdPath(home: string): string;
21
+ export declare function legacyListenInstalled(opts: {
22
+ home: string;
23
+ platform: LegacyListenPlatform;
24
+ existsSync?: (p: string) => boolean;
25
+ }): boolean;
26
+ /**
27
+ * Unload and delete the legacy listen unit when it is present.
28
+ * On-disk unit files are removed on every host. launchctl, systemctl, and
29
+ * schtasks run only for the matching platform, and only through `run` when
30
+ * the caller injected one (tests never hit a real service manager).
31
+ * Missing unit is a no-op.
32
+ */
33
+ export declare function retireLegacyListen(opts: {
34
+ home: string;
35
+ platform: LegacyListenPlatform;
36
+ run?: CommandRunner;
37
+ uid?: number;
38
+ /** Windows task probe: true when the scheduled task is registered. */
39
+ windowsTaskPresent?: boolean;
40
+ }): {
41
+ removed: boolean;
42
+ };
43
+ //# sourceMappingURL=legacy-listen.d.ts.map
@@ -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();