@indigoai-us/hq-cli 5.131.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/dist/command-catalog.generated.d.ts +12 -0
  3. package/dist/command-catalog.generated.js +16 -0
  4. package/dist/commands/lanes.d.ts +15 -4
  5. package/dist/commands/lanes.js +478 -48
  6. package/dist/commands/mesh.js +24 -2
  7. package/dist/commands/update.js +1 -1
  8. package/dist/lib/bot/paths.d.ts +7 -5
  9. package/dist/lib/bot/paths.js +8 -1
  10. package/dist/lib/bot/prompt.d.ts +0 -2
  11. package/dist/lib/bot/prompt.js +5 -11
  12. package/dist/lib/bot/room-policy.d.ts +1 -24
  13. package/dist/lib/bot/room-policy.js +2 -35
  14. package/dist/lib/bot/run.d.ts +7 -8
  15. package/dist/lib/bot/run.js +21 -33
  16. package/dist/lib/bot/session.d.ts +1 -1
  17. package/dist/lib/bot/session.js +1 -1
  18. package/dist/lib/lanes/accounts.d.ts +6 -0
  19. package/dist/lib/lanes/accounts.js +2 -1
  20. package/dist/lib/lanes/config.d.ts +4 -0
  21. package/dist/lib/lanes/config.js +9 -0
  22. package/dist/lib/lanes/constants.d.ts +3 -0
  23. package/dist/lib/lanes/constants.js +3 -0
  24. package/dist/lib/lanes/index.d.ts +11 -3
  25. package/dist/lib/lanes/index.js +6 -1
  26. package/dist/lib/lanes/mesh-events.js +4 -0
  27. package/dist/lib/lanes/models.d.ts +78 -0
  28. package/dist/lib/lanes/models.js +205 -0
  29. package/dist/lib/lanes/move-account.d.ts +56 -0
  30. package/dist/lib/lanes/move-account.js +193 -0
  31. package/dist/lib/lanes/provider-rejection.d.ts +86 -0
  32. package/dist/lib/lanes/provider-rejection.js +258 -0
  33. package/dist/lib/lanes/quota.d.ts +23 -3
  34. package/dist/lib/lanes/quota.js +88 -14
  35. package/dist/lib/lanes/reconcile-install.d.ts +24 -3
  36. package/dist/lib/lanes/reconcile-install.js +48 -12
  37. package/dist/lib/lanes/reconcile.d.ts +5 -2
  38. package/dist/lib/lanes/reconcile.js +135 -13
  39. package/dist/lib/lanes/spawn.js +26 -5
  40. package/dist/lib/lanes/store.js +10 -0
  41. package/dist/lib/lanes/types.d.ts +51 -2
  42. package/dist/lib/lanes/types.js +5 -3
  43. package/dist/lib/lanes/watcher.d.ts +27 -0
  44. package/dist/lib/lanes/watcher.js +68 -5
  45. package/dist/lib/mesh/live/backfill-held.d.ts +4 -3
  46. package/dist/lib/mesh/live/backfill-held.js +37 -14
  47. package/dist/lib/mesh/live/daemon/doctor.d.ts +6 -0
  48. package/dist/lib/mesh/live/daemon/doctor.js +16 -2
  49. package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
  50. package/dist/lib/mesh/live/daemon/install.js +100 -7
  51. package/dist/lib/mesh/live/daemon/legacy-listen.d.ts +43 -0
  52. package/dist/lib/mesh/live/daemon/legacy-listen.js +89 -0
  53. package/dist/lib/mesh/live/daemon/live-cache.d.ts +26 -0
  54. package/dist/lib/mesh/live/daemon/live-cache.js +116 -0
  55. package/dist/lib/mesh/live/daemon/presence.d.ts +7 -3
  56. package/dist/lib/mesh/live/daemon/presence.js +10 -9
  57. package/dist/lib/mesh/live/daemon/run.d.ts +18 -0
  58. package/dist/lib/mesh/live/daemon/run.js +65 -6
  59. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +31 -0
  60. package/dist/lib/mesh/live/daemon/work-subscribe.js +124 -0
  61. package/dist/lib/mesh/live/emit-evidence.d.ts +19 -0
  62. package/dist/lib/mesh/live/emit-evidence.js +20 -2
  63. package/dist/lib/work-context/company.d.ts +16 -0
  64. package/dist/lib/work-context/company.js +46 -14
  65. package/dist/utils/self-update.js +4 -1
  66. package/package.json +2 -2
@@ -163,9 +163,11 @@ function localCompanyCandidates(ref, state, canonicalHqRoot, canonicalHqRoots =
163
163
  return candidates;
164
164
  }
165
165
  /**
166
- * Keep only event evidence that belongs to the current session instance. A
167
- * current state is a durable instance marker; metadata from a held event that
168
- * predates it could belong to a prior use of the same session id.
166
+ * Keep only event evidence for the current session instance. When the state
167
+ * file has a binding episode, that id is the instance. A timestamp, including
168
+ * one later than startedAt, does not make another episode current.
169
+ * Sessions that predate episodes still use startedAt so an older event is
170
+ * not treated as this instance.
169
171
  */
170
172
  function currentInstanceEventEvidence(ref, state) {
171
173
  const evidence = ref.eventEvidence ?? [
@@ -176,6 +178,9 @@ function currentInstanceEventEvidence(ref, state) {
176
178
  },
177
179
  ...(ref.hqRoots ?? []).filter((hqRoot) => hqRoot !== ref.hqRoot).map((hqRoot) => ({ hqRoot })),
178
180
  ];
181
+ if (state?.bindingEpisodeId) {
182
+ return evidence.filter((entry) => Boolean(entry.hqRoot) && entry.bindingEpisodeId === state.bindingEpisodeId);
183
+ }
179
184
  if (!state?.startedAt)
180
185
  return evidence;
181
186
  const startedAt = Date.parse(state.startedAt);
@@ -188,8 +193,19 @@ function currentInstanceEventEvidence(ref, state) {
188
193
  return Number.isFinite(eventAt) && eventAt >= startedAt;
189
194
  });
190
195
  }
196
+ /** Plain reason when every rooted held event belongs to another episode. */
197
+ function episodeMismatchReason(ref, state) {
198
+ if (!state?.bindingEpisodeId)
199
+ return null;
200
+ const rooted = (ref.eventEvidence ?? []).filter((entry) => entry.hqRoot);
201
+ if (rooted.length === 0)
202
+ return null;
203
+ if (rooted.some((entry) => entry.bindingEpisodeId === state.bindingEpisodeId))
204
+ return null;
205
+ return "episode does not match the current instance";
206
+ }
191
207
  function hasOnlyStaleEventEvidence(ref, state) {
192
- return Boolean(state?.startedAt &&
208
+ return Boolean((state?.bindingEpisodeId || state?.startedAt) &&
193
209
  ref.eventEvidence?.some((entry) => entry.hqRoot) &&
194
210
  !currentInstanceEventEvidence(ref, state).some((entry) => entry.hqRoot));
195
211
  }
@@ -310,12 +326,18 @@ function latestAcknowledgedRegistration(sessionId, operations) {
310
326
  function acknowledgedRegistrationMatches(operation, ref, evidence, state) {
311
327
  if (!operation.companyUid || !operation.receiptId)
312
328
  return false;
313
- if (!state?.startedAt)
314
- return false;
315
- const startedAt = Date.parse(state.startedAt);
316
- const registeredAt = Date.parse(operation.createdAt);
317
- if (!Number.isFinite(startedAt) || !Number.isFinite(registeredAt) || registeredAt < startedAt) {
318
- return false;
329
+ if (state?.bindingEpisodeId) {
330
+ if (state.bindingEpisodeId !== operation.receiptId)
331
+ return false;
332
+ }
333
+ else {
334
+ if (!state?.startedAt)
335
+ return false;
336
+ const startedAt = Date.parse(state.startedAt);
337
+ const registeredAt = Date.parse(operation.createdAt);
338
+ if (!Number.isFinite(startedAt) || !Number.isFinite(registeredAt) || registeredAt < startedAt) {
339
+ return false;
340
+ }
319
341
  }
320
342
  if (evidence &&
321
343
  !candidatesAgree(evidence, {
@@ -325,8 +347,6 @@ function acknowledgedRegistrationMatches(operation, ref, evidence, state) {
325
347
  return false;
326
348
  if (ref.harness && operation.harness && ref.harness !== operation.harness)
327
349
  return false;
328
- if (state?.bindingEpisodeId && state.bindingEpisodeId !== operation.receiptId)
329
- return false;
330
350
  return true;
331
351
  }
332
352
  /** Shared doctor/backfill gate for a receipt that is safe to treat as current evidence. */
@@ -407,8 +427,9 @@ export function readHeldSessions(workMeshRoot) {
407
427
  if (task && !ref.task)
408
428
  ref.task = task;
409
429
  const at = str(event.at) ?? (envelope ? str(envelope.heldAt) : undefined);
430
+ const bindingEpisodeId = str(event.bindingEpisodeId);
410
431
  if (hqRoot || cwd) {
411
- ref.eventEvidence = [...(ref.eventEvidence ?? []), { hqRoot, cwd, harness, at }];
432
+ ref.eventEvidence = [...(ref.eventEvidence ?? []), { hqRoot, cwd, harness, at, bindingEpisodeId }];
412
433
  }
413
434
  seen.set(sessionId, ref);
414
435
  }
@@ -481,7 +502,9 @@ export async function backfillHeldSessions(deps) {
481
502
  if (!evidence.candidate?.slug && deps.useDeviceDefault !== true) {
482
503
  result.unresolved += 1;
483
504
  result.noEvidence += 1;
484
- deps.log?.(`would stay unresolved ${sessionId} (${hasOnlyStaleEventEvidence(ref, state) ? "STALE_EVIDENCE" : "no local company evidence"})`);
505
+ const staleReason = episodeMismatchReason(ref, state)
506
+ ?? (hasOnlyStaleEventEvidence(ref, state) ? "STALE_EVIDENCE" : "no local company evidence");
507
+ deps.log?.(`would stay unresolved ${sessionId} (${staleReason})`);
485
508
  continue;
486
509
  }
487
510
  const evidenceRef = {
@@ -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))
@@ -126,7 +127,7 @@ export function collectDaemonDoctor(deps = {}) {
126
127
  if (deadLetterCount > 0) {
127
128
  unhealthyReasons.push(`dead-letter count=${deadLetterCount}`);
128
129
  }
129
- if (emitMode === "legacy" && heldCount > 0) {
130
+ if (heldCount > 0) {
130
131
  unhealthyReasons.push(`held events awaiting company attribution=${heldCount}`);
131
132
  }
132
133
  if (heldWithNoLocalEvidence > 0) {
@@ -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,12 +210,16 @@ 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") {
206
217
  // Legacy emit path only: the spool/held/outbox queues do not exist in
207
218
  // receive-only (direct) mode.
208
- lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}`, `held with no local evidence: ${report.heldWithNoLocalEvidence}`, `outbox depth: ${report.outboxDepth}`);
219
+ lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}${report.heldCount > 0 ? " (needs_company; not sent as NONE)" : ""}`, `held with no local evidence: ${report.heldWithNoLocalEvidence}`, `outbox depth: ${report.outboxDepth}`);
220
+ }
221
+ if (report.emitMode !== "legacy" && report.heldCount > 0) {
222
+ lines.push(`held: ${report.heldCount} (needs_company; not sent as NONE)`);
209
223
  }
210
224
  lines.push(`attributed by device default: ${report.attributedByDeviceDefault}`, `device-default authorization denials: ${report.deviceDefaultAuthorizationDenials}`, `dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
211
225
  `(spool=${report.unattributedEvents.spool} held=${report.unattributedEvents.held} dead-letter=${report.unattributedEvents.deadLetter})`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`, `identity whoami: ${report.whoamiSubject}`, `identity daemon: ${report.daemonSubject}`);
@@ -101,7 +101,12 @@ export declare function restartDaemonServiceIfInstalled(opts?: {
101
101
  platform?: DaemonInstallPlatform;
102
102
  home?: string;
103
103
  uid?: number;
104
+ /** HQ root the daemon was serving, or the root this update was run from. */
105
+ hqRoot?: string;
106
+ env?: NodeJS.ProcessEnv;
104
107
  existsSync?: typeof fs.existsSync;
108
+ readFileSync?: typeof fs.readFileSync;
109
+ writeFileSync?: typeof fs.writeFileSync;
105
110
  run?: (command: string, args: string[]) => {
106
111
  status: number | null;
107
112
  };
@@ -230,6 +235,13 @@ export declare function installDaemonService(opts: {
230
235
  schtasks?: SchtasksRunner;
231
236
  /** Windows: terminate the recorded daemon pid when replacing a task (tests inject). */
232
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;
233
245
  }): InstallResult;
234
246
  export declare function uninstallDaemonService(opts: {
235
247
  platform?: DaemonInstallPlatform;
@@ -17,8 +17,9 @@ import * as path from "node:path";
17
17
  import { spawnSync } from "node:child_process";
18
18
  import { resolveDaemonAssetDir } from "./log.js";
19
19
  import { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, WINDOWS_TASK_NAME, daemonDir, daemonLogPath, daemonPidPath, } from "./paths.js";
20
- import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot } from "./config.js";
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
@@ -49,6 +50,86 @@ export const defaultSchtasks = (args) => {
49
50
  stderr: r.error ? r.error.message : (r.stderr ?? ""),
50
51
  };
51
52
  };
53
+ function readServiceText(file, existsSync, readFileSync) {
54
+ if (!existsSync(file))
55
+ return "";
56
+ const raw = readFileSync(file);
57
+ const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw));
58
+ if ((buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) || buf.includes(0)) {
59
+ return buf.toString("utf16le").replace(/^\uFEFF/, "");
60
+ }
61
+ return buf.toString("utf8");
62
+ }
63
+ function hqRootFromUnitText(platform, text) {
64
+ if (platform === "win32")
65
+ return parseWindowsTaskHqRoot(text);
66
+ if (platform === "darwin") {
67
+ const match = /<key>HQ_ROOT<\/key>\s*<string>([^<]*)<\/string>/.exec(text);
68
+ return match?.[1] ?? null;
69
+ }
70
+ const match = /^Environment=HQ_ROOT=(.*)$/m.exec(text);
71
+ if (!match)
72
+ return null;
73
+ const value = match[1].trim();
74
+ return value.startsWith("'") && value.endsWith("'") ? value.slice(1, -1) : value;
75
+ }
76
+ /**
77
+ * Record the HQ root the daemon was serving and replace a pre-allowlist unit
78
+ * before the process is restarted. A unit with no HQ_ROOT (or Restart=on-failure)
79
+ * comes back with an empty root allowlist unless this runs first.
80
+ */
81
+ function migrateLegacyDaemonUnit(opts) {
82
+ const { platform, home } = opts;
83
+ if (platform !== "darwin" && platform !== "linux" && platform !== "win32")
84
+ return;
85
+ const unitPath = platform === "darwin"
86
+ ? path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`)
87
+ : platform === "linux"
88
+ ? path.join(home, ".config", "systemd", "user", SYSTEMD_UNIT_NAME)
89
+ : path.join(daemonDir(undefined, home, opts.env), `${WINDOWS_TASK_NAME}.xml`);
90
+ if (!opts.existsSync(unitPath))
91
+ return;
92
+ const text = readServiceText(unitPath, opts.existsSync, opts.readFileSync);
93
+ const fromUnit = hqRootFromUnitText(platform, text);
94
+ const verified = verifyHqRoot(fromUnit ?? undefined)
95
+ ?? verifyHqRoot(opts.hqRoot)
96
+ ?? verifyHqRoot(opts.env?.HQ_ROOT);
97
+ if (!verified)
98
+ return;
99
+ registerTrustedHqRoot(daemonDir(undefined, home, opts.env), verified);
100
+ const legacy = platform === "linux"
101
+ ? !text.includes("Environment=HQ_ROOT=") || /Restart=on-failure/.test(text)
102
+ : platform === "darwin"
103
+ ? !/<key>HQ_ROOT<\/key>/.test(text)
104
+ : !fromUnit;
105
+ if (!legacy)
106
+ return;
107
+ const paths = buildInstallPaths({
108
+ home,
109
+ env: opts.env,
110
+ hqRoot: verified,
111
+ hqBinary: "/usr/bin/hq",
112
+ nodeBinary: process.execPath || "/usr/bin/node",
113
+ });
114
+ try {
115
+ paths.hqBinary = resolveHqBinary(process.argv, opts.env ?? process.env);
116
+ }
117
+ catch {
118
+ // The rendered command only has to name a binary; install already resolved one.
119
+ }
120
+ if (platform === "darwin") {
121
+ opts.writeFileSync(unitPath, renderLaunchdPlist(paths), { mode: 0o600 });
122
+ return;
123
+ }
124
+ if (platform === "linux") {
125
+ opts.writeFileSync(unitPath, renderSystemdUserUnit(paths), { mode: 0o600 });
126
+ opts.run("systemctl", ["--user", "daemon-reload"]);
127
+ return;
128
+ }
129
+ const rendered = renderWindowsTaskXml(paths);
130
+ opts.writeFileSync(unitPath, encodeWindowsTaskXml(rendered));
131
+ opts.run("schtasks.exe", ["/Create", "/TN", WINDOWS_TASK_NAME, "/XML", unitPath, "/F"]);
132
+ }
52
133
  /**
53
134
  * Restart the installed user daemon after a CLI upgrade. This is deliberately
54
135
  * best-effort: an update must remain successful if a user has no service,
@@ -58,8 +139,11 @@ export function restartDaemonServiceIfInstalled(opts = {}) {
58
139
  const platform = opts.platform ?? detectPlatform();
59
140
  const home = opts.home ?? os.homedir();
60
141
  const existsSync = opts.existsSync ?? fs.existsSync;
142
+ const readFileSync = opts.readFileSync ?? fs.readFileSync;
143
+ const writeFileSync = opts.writeFileSync ?? fs.writeFileSync;
61
144
  const run = opts.run ?? ((command, args) => spawnSync(command, args, { stdio: "ignore" }));
62
145
  try {
146
+ migrateLegacyDaemonUnit({ platform, home, hqRoot: opts.hqRoot, env: opts.env, existsSync, readFileSync, writeFileSync, run });
63
147
  if (platform === "darwin") {
64
148
  const plist = path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
65
149
  if (!existsSync(plist))
@@ -568,6 +652,15 @@ export function installDaemonService(opts) {
568
652
  const mkdirSync = opts.mkdirSync ?? fs.mkdirSync;
569
653
  mkdirSync(paths.daemonDir, { recursive: true, mode: 0o700 });
570
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
+ : "";
571
664
  if (platform === "darwin") {
572
665
  const rendered = renderLaunchdPlist(paths);
573
666
  mkdirSync(paths.launchAgentsDir, { recursive: true });
@@ -579,7 +672,7 @@ export function installDaemonService(opts) {
579
672
  paths,
580
673
  rendered,
581
674
  dest: paths.plistPath,
582
- message: `Installed LaunchAgent ${LAUNCHD_LABEL} → ${paths.plistPath}`,
675
+ message: `Installed LaunchAgent ${LAUNCHD_LABEL} → ${paths.plistPath}${legacyNote}`,
583
676
  };
584
677
  }
585
678
  if (platform === "linux") {
@@ -599,7 +692,7 @@ export function installDaemonService(opts) {
599
692
  dest,
600
693
  message: `Wrote system unit template for user ${opts.systemUser ?? paths.systemUser} at ${dest}. ` +
601
694
  `Install with: sudo cp ${dest} /etc/systemd/system/${SYSTEMD_UNIT_NAME} && ` +
602
- `sudo systemctl daemon-reload && sudo systemctl enable --now ${SYSTEMD_UNIT_NAME}`,
695
+ `sudo systemctl daemon-reload && sudo systemctl enable --now ${SYSTEMD_UNIT_NAME}${legacyNote}`,
603
696
  };
604
697
  }
605
698
  const rendered = renderSystemdUserUnit(paths);
@@ -613,7 +706,7 @@ export function installDaemonService(opts) {
613
706
  paths,
614
707
  rendered,
615
708
  dest: paths.systemdUnitPath,
616
- message: `Installed systemd user unit ${SYSTEMD_UNIT_NAME} → ${paths.systemdUnitPath}`,
709
+ message: `Installed systemd user unit ${SYSTEMD_UNIT_NAME} → ${paths.systemdUnitPath}${legacyNote}`,
617
710
  };
618
711
  }
619
712
  if (platform === "win32") {
@@ -645,9 +738,9 @@ export function installDaemonService(opts) {
645
738
  dest: paths.windowsTaskXmlPath,
646
739
  running,
647
740
  message: running
648
- ? `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (started; ${mode})`
741
+ ? `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (started; ${mode})${legacyNote}`
649
742
  : `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (${mode}); ` +
650
- `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}`,
651
744
  notice: windowsLogonNotice(paths),
652
745
  };
653
746
  }
@@ -655,7 +748,7 @@ export function installDaemonService(opts) {
655
748
  platform,
656
749
  action: "install",
657
750
  paths,
658
- 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}`,
659
752
  };
660
753
  }
661
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