@indigoai-us/hq-cli 5.345.191 → 5.345.194

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 (129) hide show
  1. package/CHANGELOG.md +431 -0
  2. package/assets/bot-workers/setup/worker.yaml +91 -34
  3. package/dist/command-catalog.generated.d.ts +145 -19
  4. package/dist/command-catalog.generated.js +184 -21
  5. package/dist/commands/bot-jobs.d.ts +21 -18
  6. package/dist/commands/bot-jobs.js +127 -39
  7. package/dist/commands/bot.d.ts +4 -3
  8. package/dist/commands/bot.js +265 -140
  9. package/dist/commands/core-jobs-schedule.js +10 -0
  10. package/dist/commands/daemon.js +37 -0
  11. package/dist/commands/files.d.ts +41 -6
  12. package/dist/commands/files.js +108 -27
  13. package/dist/commands/pack-start-bot.d.ts +4 -6
  14. package/dist/commands/pack-start-bot.js +4 -8
  15. package/dist/commands/signals-run.d.ts +46 -0
  16. package/dist/commands/signals-run.js +180 -0
  17. package/dist/commands/signals.d.ts +1 -0
  18. package/dist/commands/signals.js +3 -0
  19. package/dist/commands/sources.d.ts +58 -0
  20. package/dist/commands/sources.js +294 -26
  21. package/dist/commands/staff/reset-test-account.d.ts +10 -0
  22. package/dist/commands/staff/reset-test-account.js +18 -2
  23. package/dist/commands/whoami.d.ts +7 -0
  24. package/dist/commands/whoami.js +35 -2
  25. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +31171 -52512
  26. package/dist/hq-cloud-sync-runner/package.json +1 -1
  27. package/dist/lib/bot/api.d.ts +3 -2
  28. package/dist/lib/bot/config.d.ts +2 -0
  29. package/dist/lib/bot/fleet-cron.d.ts +121 -0
  30. package/dist/lib/bot/fleet-cron.js +319 -0
  31. package/dist/lib/bot/fleet-local.d.ts +93 -1
  32. package/dist/lib/bot/fleet-local.js +546 -34
  33. package/dist/lib/bot/fleet-owner-companies.d.ts +12 -0
  34. package/dist/lib/bot/fleet-owner-companies.js +30 -0
  35. package/dist/lib/bot/fleet-watch.d.ts +25 -0
  36. package/dist/lib/bot/fleet-watch.js +51 -0
  37. package/dist/lib/bot/index.d.ts +1 -1
  38. package/dist/lib/bot/index.js +1 -1
  39. package/dist/lib/bot/job-run.d.ts +5 -4
  40. package/dist/lib/bot/job-run.js +5 -4
  41. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +57 -3
  42. package/dist/lib/bot/jobs/hot-signal-sync.js +163 -4
  43. package/dist/lib/bot/local-jobs.d.ts +13 -2
  44. package/dist/lib/bot/local-jobs.js +17 -4
  45. package/dist/lib/bot/progress.d.ts +14 -0
  46. package/dist/lib/bot/progress.js +47 -1
  47. package/dist/lib/bot/run.d.ts +55 -2
  48. package/dist/lib/bot/run.js +192 -10
  49. package/dist/lib/bot/setup-tool-offer.d.ts +1 -0
  50. package/dist/lib/bot/setup-tool-offer.js +16 -0
  51. package/dist/lib/bot/signals-engine-flag.d.ts +18 -0
  52. package/dist/lib/bot/signals-engine-flag.js +30 -0
  53. package/dist/lib/daemon/catalog.d.ts +4 -0
  54. package/dist/lib/daemon/catalog.js +29 -9
  55. package/dist/lib/daemon/legacy.d.ts +6 -0
  56. package/dist/lib/daemon/legacy.js +16 -10
  57. package/dist/lib/daemon/run.d.ts +1 -0
  58. package/dist/lib/daemon/run.js +10 -0
  59. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  60. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  61. package/dist/lib/flag-registry.d.ts +13 -1
  62. package/dist/lib/flag-registry.js +46 -0
  63. package/dist/lib/hot-channels/adapters/attio.d.ts +75 -2
  64. package/dist/lib/hot-channels/adapters/attio.js +367 -29
  65. package/dist/lib/hot-channels/adapters/gmail.d.ts +14 -0
  66. package/dist/lib/hot-channels/adapters/gmail.js +59 -2
  67. package/dist/lib/hot-channels/adapters/linear.d.ts +24 -0
  68. package/dist/lib/hot-channels/adapters/linear.js +71 -1
  69. package/dist/lib/hot-channels/adapters/meetings.js +4 -1
  70. package/dist/lib/hot-channels/adapters/slack.js +7 -3
  71. package/dist/lib/hot-channels/alert.js +1 -1
  72. package/dist/lib/hot-channels/contract.d.ts +11 -0
  73. package/dist/lib/hot-channels/contract.js +1 -0
  74. package/dist/lib/hot-channels/cursors.js +1 -1
  75. package/dist/lib/hot-channels/landing.d.ts +6 -2
  76. package/dist/lib/hot-channels/landing.js +13 -6
  77. package/dist/lib/hot-channels/ledger.js +2 -0
  78. package/dist/lib/hot-channels/loop.d.ts +6 -0
  79. package/dist/lib/hot-channels/loop.js +19 -4
  80. package/dist/lib/hot-channels/registry.js +9 -1
  81. package/dist/lib/hot-channels/run.d.ts +18 -0
  82. package/dist/lib/hot-channels/run.js +15 -4
  83. package/dist/lib/hot-channels/signals-pass.d.ts +34 -2
  84. package/dist/lib/hot-channels/signals-pass.js +149 -38
  85. package/dist/lib/hot-channels/signals-redo.d.ts +60 -0
  86. package/dist/lib/hot-channels/signals-redo.js +207 -0
  87. package/dist/lib/hot-channels/sync-lock.js +22 -6
  88. package/dist/lib/mesh/live/daemon/install.d.ts +22 -0
  89. package/dist/lib/mesh/live/daemon/install.js +117 -30
  90. package/dist/lib/signals/adapters-catalog.d.ts +77 -0
  91. package/dist/lib/signals/adapters-catalog.js +194 -0
  92. package/dist/lib/signals/engine.d.ts +202 -0
  93. package/dist/lib/signals/engine.js +504 -0
  94. package/dist/lib/signals/failure-alert.d.ts +89 -0
  95. package/dist/lib/signals/failure-alert.js +175 -0
  96. package/dist/lib/signals/local-date.d.ts +3 -0
  97. package/dist/lib/signals/local-date.js +6 -0
  98. package/dist/lib/signals/projection.d.ts +42 -0
  99. package/dist/lib/signals/projection.js +67 -0
  100. package/dist/lib/signals/reply.d.ts +112 -0
  101. package/dist/lib/signals/reply.js +368 -0
  102. package/dist/lib/signals/run-lock.d.ts +36 -0
  103. package/dist/lib/signals/run-lock.js +148 -0
  104. package/dist/lib/signals/run-record.d.ts +24 -0
  105. package/dist/lib/signals/run-record.js +82 -0
  106. package/dist/lib/signals/sanitize.d.ts +105 -0
  107. package/dist/lib/signals/sanitize.js +439 -0
  108. package/dist/lib/signals/settings.d.ts +10 -0
  109. package/dist/lib/signals/settings.js +48 -0
  110. package/dist/lib/signals/steps/adapters.d.ts +14 -0
  111. package/dist/lib/signals/steps/adapters.js +156 -0
  112. package/dist/lib/signals/steps/entity-similarity.d.ts +10 -0
  113. package/dist/lib/signals/steps/entity-similarity.js +47 -0
  114. package/dist/lib/signals/steps/extract.d.ts +118 -0
  115. package/dist/lib/signals/steps/extract.js +793 -0
  116. package/dist/lib/signals/steps/garden.d.ts +24 -0
  117. package/dist/lib/signals/steps/garden.js +250 -0
  118. package/dist/lib/signals/steps/types.d.ts +117 -0
  119. package/dist/lib/signals/steps/types.js +4 -0
  120. package/dist/lib/signals/steps/upload.d.ts +43 -0
  121. package/dist/lib/signals/steps/upload.js +324 -0
  122. package/dist/lib/signals/write-guard.d.ts +63 -0
  123. package/dist/lib/signals/write-guard.js +287 -0
  124. package/dist/utils/resident-package-preload.js +16 -0
  125. package/dist/utils/vault-api.d.ts +1 -0
  126. package/dist/utils/vault-api.js +1 -1
  127. package/package.json +9 -9
  128. package/dist/lib/bot/fleet-default-flag.d.ts +0 -31
  129. package/dist/lib/bot/fleet-default-flag.js +0 -127
@@ -17,6 +17,7 @@ import { randomUUID } from "node:crypto";
17
17
  import * as fs from "node:fs/promises";
18
18
  import * as os from "node:os";
19
19
  import * as path from "node:path";
20
+ import { assertVaultPath } from "../signals/write-guard.js";
20
21
  import { assertNotIntegrationSecretsPath, personDirName } from "./cursors.js";
21
22
  /** A lock whose last heartbeat is older than this is stale. */
22
23
  export const SYNC_LOCK_STALE_MS = 30 * 60 * 1000;
@@ -42,7 +43,8 @@ export function syncLockPath(vaultRoot, personKey) {
42
43
  function serialize(body) {
43
44
  return `${JSON.stringify(body)}\n`;
44
45
  }
45
- async function tryCreate(file, body) {
46
+ async function tryCreate(file, body, check) {
47
+ check(file);
46
48
  try {
47
49
  await fs.writeFile(file, serialize(body), { encoding: "utf8", flag: "wx" });
48
50
  return true;
@@ -102,6 +104,10 @@ export async function acquireSyncLock(vaultRoot, personKey, options = {}) {
102
104
  const heartbeatMs = options.heartbeatMs ?? SYNC_LOCK_HEARTBEAT_MS;
103
105
  const alive = options.pidAlive ?? pidAlive;
104
106
  const file = syncLockPath(vaultRoot, personKey);
107
+ const check = (p) => {
108
+ assertVaultPath(vaultRoot, p);
109
+ };
110
+ check(file);
105
111
  await fs.mkdir(path.dirname(file), { recursive: true });
106
112
  const startedAt = now().toISOString();
107
113
  const body = { pid: process.pid, host: os.hostname(), startedAt, heartbeatAt: startedAt };
@@ -120,6 +126,8 @@ export async function acquireSyncLock(vaultRoot, personKey, options = {}) {
120
126
  if (released || !(await ownsFile()))
121
127
  return;
122
128
  const tmp = `${file}.${randomUUID()}.tmp`;
129
+ check(tmp);
130
+ check(file);
123
131
  await fs.writeFile(tmp, serialize({ ...body, heartbeatAt: now().toISOString() }), "utf8");
124
132
  await fs.rename(tmp, file);
125
133
  };
@@ -140,19 +148,21 @@ export async function acquireSyncLock(vaultRoot, personKey, options = {}) {
140
148
  released = true;
141
149
  if (timer)
142
150
  clearInterval(timer);
143
- if (await ownsFile())
151
+ if (await ownsFile()) {
152
+ check(file);
144
153
  await fs.rm(file, { force: true });
154
+ }
145
155
  },
146
156
  };
147
157
  };
148
- if (await tryCreate(file, body))
158
+ if (await tryCreate(file, body, check))
149
159
  return held();
150
160
  const stale = await isStale(file, now().getTime(), staleMs, alive);
151
161
  if (stale === false)
152
162
  return null;
153
- if (stale === true && !(await takeOverStale(file, now, staleMs, alive)))
163
+ if (stale === true && !(await takeOverStale(file, now, staleMs, alive, check)))
154
164
  return null;
155
- return (await tryCreate(file, body)) ? held() : null;
165
+ return (await tryCreate(file, body, check)) ? held() : null;
156
166
  }
157
167
  /** A takeover guard older than this belongs to a contender that died mid-takeover. */
158
168
  const TAKEOVER_GUARD_STALE_MS = 60 * 1000;
@@ -164,8 +174,10 @@ const TAKEOVER_GUARD_STALE_MS = 60 * 1000;
164
174
  * (its holder released and a new run took the lock), it is put back. Returns
165
175
  * false when this contender should give up.
166
176
  */
167
- async function takeOverStale(file, now, staleMs, alive) {
177
+ async function takeOverStale(file, now, staleMs, alive, check) {
168
178
  const guard = `${file}.takeover`;
179
+ check(guard);
180
+ check(file);
169
181
  const takeGuard = async () => {
170
182
  try {
171
183
  await fs.writeFile(guard, `${process.pid}\n`, { encoding: "utf8", flag: "wx" });
@@ -188,6 +200,7 @@ async function takeOverStale(file, now, staleMs, alive) {
188
200
  if (age < TAKEOVER_GUARD_STALE_MS)
189
201
  return false;
190
202
  const staleGuard = `${guard}.stale-${randomUUID()}`;
203
+ check(staleGuard);
191
204
  await fs.rename(guard, staleGuard).catch(() => undefined);
192
205
  await fs.rm(staleGuard, { force: true });
193
206
  if (!(await takeGuard()))
@@ -200,6 +213,7 @@ async function takeOverStale(file, now, staleMs, alive) {
200
213
  if ((await isStale(file, now().getTime(), staleMs, alive)) !== true)
201
214
  return false;
202
215
  const aside = `${file}.stale-${process.pid}-${randomUUID()}`;
216
+ check(aside);
203
217
  try {
204
218
  await fs.rename(file, aside);
205
219
  }
@@ -210,6 +224,7 @@ async function takeOverStale(file, now, staleMs, alive) {
210
224
  }
211
225
  const moved = await fs.readFile(aside, "utf8").catch(() => null);
212
226
  if (moved !== seen) {
227
+ check(file);
213
228
  await fs.link(aside, file).catch(() => undefined);
214
229
  await fs.rm(aside, { force: true });
215
230
  return false;
@@ -218,6 +233,7 @@ async function takeOverStale(file, now, staleMs, alive) {
218
233
  return true;
219
234
  }
220
235
  finally {
236
+ check(guard);
221
237
  await fs.rm(guard, { force: true });
222
238
  }
223
239
  }
@@ -49,6 +49,11 @@ export interface InstallPaths {
49
49
  windowsTaskXmlPath: string;
50
50
  /** Windows: `DOMAIN\user` the task is registered for (logon trigger + principal). */
51
51
  windowsUser: string;
52
+ /**
53
+ * Windows: Task Scheduler name to register. Defaults to WINDOWS_TASK_NAME;
54
+ * set to the per-user name when the shared name is taken by another account.
55
+ */
56
+ windowsTaskName?: string;
52
57
  /**
53
58
  * Windows: `conhost.exe` used as `conhost --headless node hq ...` so the
54
59
  * logon-triggered daemon gets no visible console window. Undefined when the
@@ -257,6 +262,23 @@ export declare function renderSystemdSystemUnit(paths: InstallPaths): string;
257
262
  * resolveWindowsHeadlessLauncher); otherwise node is run directly.
258
263
  */
259
264
  export declare function renderWindowsTaskXml(paths: InstallPaths): string;
265
+ /** Sidecar under the daemon dir naming the task this user registered. */
266
+ export declare const WINDOWS_TASK_NAME_FILE = "windows-task-name";
267
+ /**
268
+ * Per-user task name, e.g. `ai.getindigo.hq-mesh-daemon.alice`. Task names
269
+ * live in one machine-wide namespace: when the shared name already belongs to
270
+ * a task another account registered, a standard user cannot see, replace, or
271
+ * delete it, and `schtasks /Create /F` fails with "Access is denied". The
272
+ * per-user name avoids that collision.
273
+ */
274
+ export declare function windowsPerUserTaskName(windowsUser: string): string;
275
+ /** Task name this user's install registered (sidecar), else the shared name. */
276
+ export declare function installedWindowsTaskName(dir: string, deps?: {
277
+ existsSync?: typeof fs.existsSync;
278
+ readFileSync?: typeof fs.readFileSync;
279
+ }): string;
280
+ /** Recorded mesh task name for a home dir (`hq daemon migrate` legacy probe). */
281
+ export declare function recordedWindowsMeshTaskName(home: string, env?: NodeJS.ProcessEnv): string;
260
282
  /** schtasks stores task XML as UTF-16 LE with BOM; match it so /XML parses. */
261
283
  export declare function encodeWindowsTaskXml(rendered: string): Buffer;
262
284
  /** Status column of `schtasks /Query /FO CSV /NH`: "TaskName","Next Run Time","Status". */
@@ -137,9 +137,14 @@ function migrateLegacyDaemonUnit(opts) {
137
137
  });
138
138
  return;
139
139
  }
140
+ const taskName = installedWindowsTaskName(path.dirname(unitPath), {
141
+ existsSync: opts.existsSync,
142
+ readFileSync: opts.readFileSync,
143
+ });
144
+ paths.windowsTaskName = taskName;
140
145
  const rendered = renderWindowsTaskXml(paths);
141
146
  replaceUnitFile(unitPath, encodeWindowsTaskXml(rendered), 0o600, () => {
142
- const result = opts.run("schtasks.exe", ["/Create", "/TN", WINDOWS_TASK_NAME, "/XML", unitPath, "/F"]);
147
+ const result = opts.run("schtasks.exe", ["/Create", "/TN", taskName, "/XML", unitPath, "/F"]);
143
148
  if (result.status !== 0)
144
149
  throw new Error("schtasks /Create failed while repairing the mesh unit");
145
150
  });
@@ -230,9 +235,10 @@ export function restartDaemonServiceIfInstalled(opts = {}) {
230
235
  const xml = path.join(dir, `${WINDOWS_TASK_NAME}.xml`);
231
236
  if (!existsSync(xml))
232
237
  return { attempted: false, restarted: false };
233
- run("schtasks.exe", ["/End", "/TN", WINDOWS_TASK_NAME]);
238
+ const taskName = installedWindowsTaskName(dir, { existsSync, readFileSync });
239
+ run("schtasks.exe", ["/End", "/TN", taskName]);
234
240
  stopWindowsDaemonProcess(dir, { existsSync, kill: opts.kill });
235
- const result = run("schtasks.exe", ["/Run", "/TN", WINDOWS_TASK_NAME]);
241
+ const result = run("schtasks.exe", ["/Run", "/TN", taskName]);
236
242
  return { attempted: true, restarted: result.status === 0 };
237
243
  }
238
244
  }
@@ -692,7 +698,7 @@ export function renderWindowsTaskXml(paths) {
692
698
  return `<?xml version="1.0" encoding="UTF-16"?>
693
699
  <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
694
700
  <RegistrationInfo>
695
- <URI>\\${WINDOWS_TASK_NAME}</URI>
701
+ <URI>\\${paths.windowsTaskName ?? WINDOWS_TASK_NAME}</URI>
696
702
  <Description>HQ Work Mesh Live daemon (spool drainer + MQTT presence)</Description>
697
703
  </RegistrationInfo>
698
704
  <Triggers>
@@ -740,6 +746,50 @@ export function renderWindowsTaskXml(paths) {
740
746
  `;
741
747
  }
742
748
  const WINDOWS_TASK_LOGON_TYPE = "InteractiveToken";
749
+ /** Sidecar under the daemon dir naming the task this user registered. */
750
+ export const WINDOWS_TASK_NAME_FILE = "windows-task-name";
751
+ /**
752
+ * Per-user task name, e.g. `ai.getindigo.hq-mesh-daemon.alice`. Task names
753
+ * live in one machine-wide namespace: when the shared name already belongs to
754
+ * a task another account registered, a standard user cannot see, replace, or
755
+ * delete it, and `schtasks /Create /F` fails with "Access is denied". The
756
+ * per-user name avoids that collision.
757
+ */
758
+ export function windowsPerUserTaskName(windowsUser) {
759
+ const account = windowsUser.split("\\").pop() ?? windowsUser;
760
+ const slug = account.toLowerCase().replace(/[^a-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "");
761
+ return `${WINDOWS_TASK_NAME}.${slug || "user"}`;
762
+ }
763
+ function isValidWindowsTaskName(name) {
764
+ return name === WINDOWS_TASK_NAME || /^ai\.getindigo\.hq-mesh-daemon\.[a-z0-9_-]+$/.test(name);
765
+ }
766
+ /** Task name this user's install registered (sidecar), else the shared name. */
767
+ export function installedWindowsTaskName(dir, deps = {}) {
768
+ const existsSync = deps.existsSync ?? fs.existsSync;
769
+ const readFileSync = deps.readFileSync ?? fs.readFileSync;
770
+ const file = path.join(dir, WINDOWS_TASK_NAME_FILE);
771
+ try {
772
+ if (!existsSync(file))
773
+ return WINDOWS_TASK_NAME;
774
+ const raw = readFileSync(file);
775
+ const name = (Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw)).trim();
776
+ return isValidWindowsTaskName(name) ? name : WINDOWS_TASK_NAME;
777
+ }
778
+ catch {
779
+ return WINDOWS_TASK_NAME;
780
+ }
781
+ }
782
+ /** Recorded mesh task name for a home dir (`hq daemon migrate` legacy probe). */
783
+ export function recordedWindowsMeshTaskName(home, env = process.env) {
784
+ return installedWindowsTaskName(daemonDir(undefined, home, env));
785
+ }
786
+ function recordWindowsTaskName(dir, name) {
787
+ const file = path.join(dir, WINDOWS_TASK_NAME_FILE);
788
+ if (name === WINDOWS_TASK_NAME)
789
+ fs.rmSync(file, { force: true });
790
+ else
791
+ atomicWrite(file, `${name}\n`, { mode: 0o600 });
792
+ }
743
793
  /** schtasks stores task XML as UTF-16 LE with BOM; match it so /XML parses. */
744
794
  export function encodeWindowsTaskXml(rendered) {
745
795
  return Buffer.from(`\uFEFF${rendered}`, "utf16le");
@@ -1015,21 +1065,52 @@ export function installDaemonService(opts) {
1015
1065
  }
1016
1066
  if (platform === "win32") {
1017
1067
  const schtasks = opts.schtasks ?? defaultSchtasks;
1018
- const rendered = renderWindowsTaskXml(paths);
1019
- replaceUnitFile(paths.windowsTaskXmlPath, encodeWindowsTaskXml(rendered), 0o600, () => {
1020
- const created = schtasks([
1021
- "/Create", "/TN", WINDOWS_TASK_NAME, "/XML", paths.windowsTaskXmlPath, "/F",
1022
- ]);
1023
- if (created.status !== 0) {
1024
- throw new Error(`schtasks /Create failed for ${WINDOWS_TASK_NAME} (exit ${created.status ?? "signal"}): ` +
1025
- `${(created.stderr || created.stdout).trim()}`);
1068
+ const create = (name) => {
1069
+ const rendered = renderWindowsTaskXml({ ...paths, windowsTaskName: name });
1070
+ let error = null;
1071
+ try {
1072
+ replaceUnitFile(paths.windowsTaskXmlPath, encodeWindowsTaskXml(rendered), 0o600, () => {
1073
+ const created = schtasks(["/Create", "/TN", name, "/XML", paths.windowsTaskXmlPath, "/F"]);
1074
+ if (created.status !== 0) {
1075
+ throw new Error(`schtasks /Create failed for ${name} (exit ${created.status ?? "signal"}): ` +
1076
+ `${(created.stderr || created.stdout).trim()}`);
1077
+ }
1078
+ recordWindowsTaskName(paths.daemonDir, name);
1079
+ });
1026
1080
  }
1027
- });
1081
+ catch (err) {
1082
+ error = err instanceof Error ? err.message : String(err);
1083
+ }
1084
+ return { rendered, error };
1085
+ };
1086
+ // Try the name this user registered before (or the shared name). If the
1087
+ // shared name fails and this user cannot even query it, the task belongs
1088
+ // to another account, which a standard user cannot replace ("Access is
1089
+ // denied"); register a per-user name instead. A shared task this user can
1090
+ // query is their own, so its failure is reported rather than leaving two
1091
+ // logon tasks behind. Both attempts use the same unprivileged XML (logon
1092
+ // trigger and principal scoped to this user, InteractiveToken, LeastPrivilege).
1093
+ const perUser = windowsPerUserTaskName(paths.windowsUser);
1094
+ let taskName = paths.windowsTaskName ?? installedWindowsTaskName(paths.daemonDir);
1095
+ let attempt = create(taskName);
1096
+ const sharedHeldElsewhere = () => taskName === WINDOWS_TASK_NAME
1097
+ && schtasks(["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "CSV", "/NH"]).status !== 0;
1098
+ if (attempt.error && taskName !== perUser && sharedHeldElsewhere()) {
1099
+ const firstError = attempt.error;
1100
+ taskName = perUser;
1101
+ attempt = create(taskName);
1102
+ if (attempt.error)
1103
+ throw new Error(`${firstError}; per-user fallback: ${attempt.error}`);
1104
+ }
1105
+ else if (attempt.error) {
1106
+ throw new Error(attempt.error);
1107
+ }
1108
+ const rendered = attempt.rendered;
1028
1109
  // /F replaced any previous registration without ending its instance; stop
1029
1110
  // the recorded daemon so the fresh task instance can take the pid lock.
1030
- schtasks(["/End", "/TN", WINDOWS_TASK_NAME]);
1111
+ schtasks(["/End", "/TN", taskName]);
1031
1112
  stopWindowsDaemonProcess(paths.daemonDir, { kill: opts.kill });
1032
- const started = schtasks(["/Run", "/TN", WINDOWS_TASK_NAME]);
1113
+ const started = schtasks(["/Run", "/TN", taskName]);
1033
1114
  const running = started.status === 0;
1034
1115
  const mode = describeWindowsLaunchMode(paths);
1035
1116
  const rootLine = paths.hqRoot
@@ -1038,13 +1119,13 @@ export function installDaemonService(opts) {
1038
1119
  return {
1039
1120
  platform,
1040
1121
  action: "install",
1041
- paths,
1122
+ paths: { ...paths, windowsTaskName: taskName },
1042
1123
  rendered,
1043
1124
  dest: paths.windowsTaskXmlPath,
1044
1125
  running,
1045
1126
  message: running
1046
- ? `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (started; ${mode})${legacyNote}`
1047
- : `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (${mode}); ` +
1127
+ ? `${rootLine}Installed Scheduled Task ${taskName} → ${paths.windowsTaskXmlPath} (started; ${mode})${legacyNote}`
1128
+ : `${rootLine}Installed Scheduled Task ${taskName} → ${paths.windowsTaskXmlPath} (${mode}); ` +
1048
1129
  `start failed: ${(started.stderr || started.stdout).trim()}. It starts at next logon.${legacyNote}`,
1049
1130
  notice: windowsLogonNotice(paths),
1050
1131
  };
@@ -1063,26 +1144,30 @@ export function uninstallDaemonService(opts) {
1063
1144
  const existsSync = opts.existsSync ?? fs.existsSync;
1064
1145
  if (platform === "win32") {
1065
1146
  const schtasks = opts.schtasks ?? defaultSchtasks;
1066
- const registered = schtasks(["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "CSV", "/NH"]).status === 0;
1147
+ const taskName = paths.windowsTaskName ?? installedWindowsTaskName(paths.daemonDir, { existsSync });
1148
+ const registered = schtasks(["/Query", "/TN", taskName, "/FO", "CSV", "/NH"]).status === 0;
1067
1149
  if (registered) {
1068
- schtasks(["/End", "/TN", WINDOWS_TASK_NAME]);
1150
+ schtasks(["/End", "/TN", taskName]);
1069
1151
  stopWindowsDaemonProcess(paths.daemonDir, { existsSync, kill: opts.kill });
1070
- const deleted = schtasks(["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"]);
1152
+ const deleted = schtasks(["/Delete", "/TN", taskName, "/F"]);
1071
1153
  if (deleted.status !== 0) {
1072
- throw new Error(`schtasks /Delete failed for ${WINDOWS_TASK_NAME} (exit ${deleted.status ?? "signal"}): ` +
1154
+ throw new Error(`schtasks /Delete failed for ${taskName} (exit ${deleted.status ?? "signal"}): ` +
1073
1155
  `${(deleted.stderr || deleted.stdout).trim()}`);
1074
1156
  }
1075
1157
  }
1076
1158
  if (existsSync(paths.windowsTaskXmlPath))
1077
1159
  unlinkSync(paths.windowsTaskXmlPath);
1160
+ const nameFile = path.join(paths.daemonDir, WINDOWS_TASK_NAME_FILE);
1161
+ if (existsSync(nameFile))
1162
+ unlinkSync(nameFile);
1078
1163
  return {
1079
1164
  platform,
1080
1165
  action: "uninstall",
1081
1166
  paths,
1082
1167
  dest: paths.windowsTaskXmlPath,
1083
1168
  message: registered
1084
- ? `Removed Scheduled Task ${WINDOWS_TASK_NAME}`
1085
- : `Scheduled Task ${WINDOWS_TASK_NAME} was not registered; removed local task XML if present`,
1169
+ ? `Removed Scheduled Task ${taskName}`
1170
+ : `Scheduled Task ${taskName} was not registered; removed local task XML if present`,
1086
1171
  };
1087
1172
  }
1088
1173
  if (platform === "darwin") {
@@ -1156,7 +1241,9 @@ export function daemonServiceStatus(opts) {
1156
1241
  // localized ("Running" / "Wird ausgeführt" / ...), so the daemon pid lock
1157
1242
  // is the source of truth for "running"; the column is shown when it
1158
1243
  // happens to carry a value, as extra context only.
1159
- const query = schtasks(["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "CSV", "/NH"]);
1244
+ const taskName = paths.windowsTaskName
1245
+ ?? installedWindowsTaskName(paths.daemonDir, { existsSync, readFileSync: opts.readFileSync });
1246
+ const query = schtasks(["/Query", "/TN", taskName, "/FO", "CSV", "/NH"]);
1160
1247
  const installed = query.status === 0;
1161
1248
  const taskState = installed ? parseSchtasksCsvStatus(query.stdout) : null;
1162
1249
  // The verbose query carries "Last Result": a non-zero, non-informational
@@ -1164,7 +1251,7 @@ export function daemonServiceStatus(opts) {
1164
1251
  // --headless), which the pid lock alone would report as merely "not
1165
1252
  // running". Surface it so a failed start never looks like a clean idle.
1166
1253
  const verbose = installed
1167
- ? schtasks(["/Query", "/TN", WINDOWS_TASK_NAME, "/V", "/FO", "CSV"])
1254
+ ? schtasks(["/Query", "/TN", taskName, "/V", "/FO", "CSV"])
1168
1255
  : null;
1169
1256
  const lastResult = verbose && verbose.status === 0 ? parseSchtasksLastResult(verbose.stdout) : null;
1170
1257
  const lastRunFailed = schtasksLastResultFailed(lastResult);
@@ -1186,14 +1273,14 @@ export function daemonServiceStatus(opts) {
1186
1273
  let message;
1187
1274
  if (!installed) {
1188
1275
  message = running
1189
- ? `Scheduled Task ${WINDOWS_TASK_NAME}: not installed (daemon running outside the task)`
1190
- : `Scheduled Task ${WINDOWS_TASK_NAME}: not installed`;
1276
+ ? `Scheduled Task ${taskName}: not installed (daemon running outside the task)`
1277
+ : `Scheduled Task ${taskName}: not installed`;
1191
1278
  }
1192
1279
  else if (running) {
1193
- message = `Scheduled Task ${WINDOWS_TASK_NAME}: running${detail}`;
1280
+ message = `Scheduled Task ${taskName}: running${detail}`;
1194
1281
  }
1195
1282
  else {
1196
- message = `Scheduled Task ${WINDOWS_TASK_NAME}: installed (daemon not running${lock === "stale" ? ", stale pid lock" : ""})${detail}`;
1283
+ message = `Scheduled Task ${taskName}: installed (daemon not running${lock === "stale" ? ", stale pid lock" : ""})${detail}`;
1197
1284
  }
1198
1285
  return {
1199
1286
  platform,
@@ -0,0 +1,77 @@
1
+ export declare const SIGNALS_ADAPTERS_CATALOG_PATH = "core/settings/signals-adapters.yaml";
2
+ export declare const FLAG_OFF_REASON = "flag-off";
3
+ export interface SignalsAdapterCatalogEntry {
4
+ id: string;
5
+ enabled: boolean;
6
+ schedule?: string;
7
+ flag?: string;
8
+ }
9
+ export interface AdapterFlag {
10
+ /** hq-flags key. */
11
+ flag: string;
12
+ /**
13
+ * The registered row's `defaultValue`: the value used when the row is
14
+ * absent, archived or unreadable.
15
+ */
16
+ defaultValue: boolean;
17
+ }
18
+ /**
19
+ * hq-flags keys that gate built-in adapters, with their registered defaults.
20
+ *
21
+ * `hq.hot-channel-slack` and `hq.hot-channel-linear` are default-on kill
22
+ * switches (indigo-feature-flags-default-on-as-fail-safes; owner pick,
23
+ * 2026-11-01): on for every company, and setting the row to false turns the
24
+ * adapter off. An absent or unregistered row keeps the adapter on. An
25
+ * unreadable registry keeps the last value read on this machine, and the
26
+ * adapter on when the flag was never read here.
27
+ */
28
+ export declare const ADAPTER_FLAGS: Readonly<Record<string, AdapterFlag>>;
29
+ /**
30
+ * Reads one company flag. `defaultValue` is the answer when the registry has
31
+ * no opinion (absent or unregistered row), or cannot be read and no value was
32
+ * read on this machine before.
33
+ */
34
+ export type AdapterFlagReader = (flag: string, defaultValue: boolean) => boolean | Promise<boolean>;
35
+ /** Where the reader keeps the last values it read: one file per company. */
36
+ export interface AdapterFlagState {
37
+ hqRoot: string;
38
+ /** Company slug: the state file is `workspace/signals/flags/{company}.json`. */
39
+ company: string;
40
+ }
41
+ export declare function adapterFlagStatePath(company: string): string;
42
+ /**
43
+ * The shared hq-flags evaluator for one company, memoized per flag for the
44
+ * life of the reader (one reader per sync run). Each read is bounded by the
45
+ * evaluator's request timeout. With `state`, a successful read is saved. A
46
+ * failed or timed-out read of a kill switch (`defaultValue` true) answers the
47
+ * last value saved for that flag and company, else `defaultValue`. A failed
48
+ * read of a default-off gate answers `defaultValue` (off): it fails closed.
49
+ * Without `state`, a failed read answers `defaultValue`.
50
+ */
51
+ export declare function companyAdapterFlagReader(companyUid: string, idToken: string, state?: AdapterFlagState): AdapterFlagReader;
52
+ /**
53
+ * Reads the local catalog; null when the file is absent. Malformed entries are
54
+ * dropped. An unreadable file or invalid yaml prints one warning to stderr and
55
+ * also answers null (no yaml gating), so a bad local file cannot break a sync.
56
+ */
57
+ export declare function readSignalsAdaptersCatalog(hqRoot: string): SignalsAdapterCatalogEntry[] | null;
58
+ export interface AdapterGating {
59
+ /** Registered adapters to run. */
60
+ enabled: string[];
61
+ /** Adapter id -> skip reason. */
62
+ skips: Record<string, string>;
63
+ /** Adapter id -> the flag that turned it off (absent for a yaml `enabled: false`). */
64
+ offFlags: Record<string, string>;
65
+ }
66
+ /** The flag gating one adapter: the yaml's `flag`, else the built-in one. */
67
+ export declare function adapterFlag(id: string, entry: SignalsAdapterCatalogEntry | undefined): AdapterFlag | undefined;
68
+ /**
69
+ * Merge the local catalog with the registered adapter ids. An adapter with
70
+ * `enabled: false`, or whose flag `isFlagOn` reads as off, is skipped with
71
+ * reason `flag-off`. Registered adapters the yaml does not list stay enabled
72
+ * unless their built-in flag is off. Yaml entries naming an unregistered
73
+ * adapter are ignored. Without `isFlagOn`, flags are not evaluated here: the
74
+ * sync evaluates them (hq-pro also gates flagged channels server-side).
75
+ */
76
+ export declare function gateAdapters(registeredIds: readonly string[], catalog: readonly SignalsAdapterCatalogEntry[] | null, isFlagOn?: AdapterFlagReader): Promise<AdapterGating>;
77
+ //# sourceMappingURL=adapters-catalog.d.ts.map
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Local adapter gating for hot-channel syncs (hot-signal-sync US-008, US-009).
3
+ *
4
+ * `core/settings/signals-adapters.yaml` (shipped by hq-core) mirrors the hq-pro
5
+ * hot-channel catalog for local gating. hq-pro stays canonical: this file can
6
+ * only turn a registered adapter off, never add one. When the file is absent
7
+ * every registered adapter is enabled.
8
+ *
9
+ * Company flags are hq-flags rows read through the shared evaluator
10
+ * (`readCompanyFlag`: person, company uid, company slug, default). There is
11
+ * no company list in code. `runCompanyHotChannelSync` applies the gate for
12
+ * every caller (`hq sources sync`, the bot job and `hq signals run`).
13
+ *
14
+ * Read failures (indigo-hq-flags-only-for-feature-gating, US-009 re-review):
15
+ * a default-on flag is an ingest kill switch, so a failed or timed-out read
16
+ * uses the last value successfully read on this machine for that flag and
17
+ * company, kept in `workspace/signals/flags/{company}.json`, and the switch's
18
+ * default (on) when it was never read here (owner pick, 2026-11-01: "On when
19
+ * never read"). A default-off gate such as hq.hot-channel-gmail (owner hold)
20
+ * fails closed: a failed read is off, whatever was read before.
21
+ */
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+ import * as yaml from "js-yaml";
25
+ import { guardedWrite } from "./write-guard.js";
26
+ export const SIGNALS_ADAPTERS_CATALOG_PATH = "core/settings/signals-adapters.yaml";
27
+ export const FLAG_OFF_REASON = "flag-off";
28
+ /**
29
+ * hq-flags keys that gate built-in adapters, with their registered defaults.
30
+ *
31
+ * `hq.hot-channel-slack` and `hq.hot-channel-linear` are default-on kill
32
+ * switches (indigo-feature-flags-default-on-as-fail-safes; owner pick,
33
+ * 2026-11-01): on for every company, and setting the row to false turns the
34
+ * adapter off. An absent or unregistered row keeps the adapter on. An
35
+ * unreadable registry keeps the last value read on this machine, and the
36
+ * adapter on when the flag was never read here.
37
+ */
38
+ export const ADAPTER_FLAGS = {
39
+ slack: { flag: "hq.hot-channel-slack", defaultValue: true },
40
+ linear: { flag: "hq.hot-channel-linear", defaultValue: true },
41
+ // US-010: registered in hq-pro with defaultValue false under an explicit
42
+ // owner hold (Gmail waits for Google's gmail.readonly verification). Setting
43
+ // the row to true turns Gmail on; an absent or unreadable row keeps it off.
44
+ // The key matches GMAIL_FLAG in hot-channels/adapters/gmail.ts.
45
+ gmail: { flag: "hq.hot-channel-gmail", defaultValue: false },
46
+ };
47
+ export function adapterFlagStatePath(company) {
48
+ return `workspace/signals/flags/${company}.json`;
49
+ }
50
+ function readLastFlags(state, companyUid) {
51
+ let doc;
52
+ try {
53
+ doc = JSON.parse(fs.readFileSync(path.join(state.hqRoot, adapterFlagStatePath(state.company)), "utf8"));
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ const d = doc;
59
+ if (!d || d.companyUid !== companyUid || typeof d.flags !== "object" || d.flags === null)
60
+ return null;
61
+ return { version: 1, companyUid, flags: d.flags };
62
+ }
63
+ /** The last value read on this machine for this flag and company, if any. */
64
+ function lastReadValue(state, companyUid, flag) {
65
+ const value = readLastFlags(state, companyUid)?.flags[flag]?.value;
66
+ return typeof value === "boolean" ? value : undefined;
67
+ }
68
+ /**
69
+ * Record a successfully read value. Read, merge and write are synchronous, so
70
+ * concurrent reads in one process cannot drop each other's values. The write
71
+ * is atomic (temp file, then rename, through the signals write guard).
72
+ */
73
+ function rememberValue(state, companyUid, flag, value) {
74
+ const doc = readLastFlags(state, companyUid) ?? { version: 1, companyUid, flags: {} };
75
+ if (doc.flags[flag]?.value === value)
76
+ return;
77
+ doc.flags[flag] = { value, readAt: new Date().toISOString() };
78
+ try {
79
+ guardedWrite(state.hqRoot, adapterFlagStatePath(state.company), `${JSON.stringify(doc, null, 2)}\n`, {
80
+ company: state.company,
81
+ });
82
+ }
83
+ catch (error) {
84
+ const errorClass = error instanceof Error ? error.name : "unknown";
85
+ console.error(`hq: Could not save the last value of ${flag} (${errorClass}).`);
86
+ }
87
+ }
88
+ /**
89
+ * The shared hq-flags evaluator for one company, memoized per flag for the
90
+ * life of the reader (one reader per sync run). Each read is bounded by the
91
+ * evaluator's request timeout. With `state`, a successful read is saved. A
92
+ * failed or timed-out read of a kill switch (`defaultValue` true) answers the
93
+ * last value saved for that flag and company, else `defaultValue`. A failed
94
+ * read of a default-off gate answers `defaultValue` (off): it fails closed.
95
+ * Without `state`, a failed read answers `defaultValue`.
96
+ */
97
+ export function companyAdapterFlagReader(companyUid, idToken, state) {
98
+ const memo = new Map();
99
+ return (flag, defaultValue) => {
100
+ const key = `${flag}\n${defaultValue}`;
101
+ let hit = memo.get(key);
102
+ if (!hit) {
103
+ // Loaded on first read: the registry module embeds its worker source.
104
+ hit = import("../flag-registry.js").then(async ({ readCompanyFlag }) => {
105
+ const read = await readCompanyFlag(companyUid, idToken, flag, { fallback: defaultValue });
106
+ if (read === null) {
107
+ if (!defaultValue || !state)
108
+ return defaultValue;
109
+ return lastReadValue(state, companyUid, flag) ?? defaultValue;
110
+ }
111
+ // A process-local override is not a registry read: answer it, keep nothing.
112
+ if (state && read.source !== "local-override")
113
+ rememberValue(state, companyUid, flag, read.value);
114
+ return read.value;
115
+ });
116
+ memo.set(key, hit);
117
+ }
118
+ return hit;
119
+ };
120
+ }
121
+ /**
122
+ * Reads the local catalog; null when the file is absent. Malformed entries are
123
+ * dropped. An unreadable file or invalid yaml prints one warning to stderr and
124
+ * also answers null (no yaml gating), so a bad local file cannot break a sync.
125
+ */
126
+ export function readSignalsAdaptersCatalog(hqRoot) {
127
+ let doc;
128
+ try {
129
+ const text = fs.readFileSync(path.join(hqRoot, SIGNALS_ADAPTERS_CATALOG_PATH), "utf8");
130
+ doc = yaml.load(text, { schema: yaml.CORE_SCHEMA });
131
+ }
132
+ catch (err) {
133
+ if (err.code === "ENOENT")
134
+ return null;
135
+ const reason = err instanceof Error ? err.message.split("\n")[0] : String(err);
136
+ process.stderr.write(`hq: warning: ignoring ${SIGNALS_ADAPTERS_CATALOG_PATH} (${reason}); running adapters without yaml gating\n`);
137
+ return null;
138
+ }
139
+ const list = Array.isArray(doc?.adapters) ? doc.adapters : [];
140
+ const out = [];
141
+ for (const raw of list) {
142
+ const e = raw;
143
+ if (!e || typeof e.id !== "string" || e.id.length === 0)
144
+ continue;
145
+ out.push({
146
+ id: e.id,
147
+ enabled: e.enabled !== false,
148
+ ...(typeof e.schedule === "string" ? { schedule: e.schedule } : {}),
149
+ ...(typeof e.flag === "string" && e.flag.length > 0 ? { flag: e.flag } : {}),
150
+ });
151
+ }
152
+ return out;
153
+ }
154
+ /** The flag gating one adapter: the yaml's `flag`, else the built-in one. */
155
+ export function adapterFlag(id, entry) {
156
+ const builtin = ADAPTER_FLAGS[id];
157
+ if (entry?.flag === undefined)
158
+ return builtin;
159
+ // A yaml flag keeps the built-in default when it names the built-in key;
160
+ // any other key is read as a kill switch (on unless the row says off).
161
+ return { flag: entry.flag, defaultValue: builtin?.flag === entry.flag ? builtin.defaultValue : true };
162
+ }
163
+ /**
164
+ * Merge the local catalog with the registered adapter ids. An adapter with
165
+ * `enabled: false`, or whose flag `isFlagOn` reads as off, is skipped with
166
+ * reason `flag-off`. Registered adapters the yaml does not list stay enabled
167
+ * unless their built-in flag is off. Yaml entries naming an unregistered
168
+ * adapter are ignored. Without `isFlagOn`, flags are not evaluated here: the
169
+ * sync evaluates them (hq-pro also gates flagged channels server-side).
170
+ */
171
+ export async function gateAdapters(registeredIds, catalog, isFlagOn) {
172
+ const byId = new Map((catalog ?? []).map((e) => [e.id, e]));
173
+ const decided = await Promise.all(registeredIds.map(async (id) => {
174
+ const entry = byId.get(id);
175
+ if (entry && !entry.enabled)
176
+ return { id, on: false, flag: undefined };
177
+ const flag = adapterFlag(id, entry);
178
+ if (!flag || !isFlagOn)
179
+ return { id, on: true, flag: undefined };
180
+ return { id, on: await isFlagOn(flag.flag, flag.defaultValue), flag: flag.flag };
181
+ }));
182
+ const gating = { enabled: [], skips: {}, offFlags: {} };
183
+ for (const { id, on, flag } of decided) {
184
+ if (on) {
185
+ gating.enabled.push(id);
186
+ continue;
187
+ }
188
+ gating.skips[id] = FLAG_OFF_REASON;
189
+ if (flag)
190
+ gating.offFlags[id] = flag;
191
+ }
192
+ return gating;
193
+ }
194
+ //# sourceMappingURL=adapters-catalog.js.map