@indigoai-us/hq-cli 5.345.147 → 5.345.149

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 (168) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +2 -0
  3. package/assets/bot-workers/setup/worker.yaml +11 -0
  4. package/dist/command-catalog.generated.d.ts +341 -11
  5. package/dist/command-catalog.generated.js +421 -11
  6. package/dist/command-help.js +2 -1
  7. package/dist/commands/access.d.ts +45 -2
  8. package/dist/commands/access.js +537 -223
  9. package/dist/commands/agent-inbox.js +2 -1
  10. package/dist/commands/billing-usage.d.ts +33 -2
  11. package/dist/commands/billing-usage.js +75 -4
  12. package/dist/commands/bot.js +2 -2
  13. package/dist/commands/channels.d.ts +27 -0
  14. package/dist/commands/channels.js +108 -4
  15. package/dist/commands/cloud.js +31 -1
  16. package/dist/commands/core-refresh-vault-access-native.d.ts +33 -0
  17. package/dist/commands/core-refresh-vault-access-native.js +356 -28
  18. package/dist/commands/core-work-mesh-bind-trusted-native.d.ts +9 -0
  19. package/dist/commands/core-work-mesh-bind-trusted-native.js +26 -19
  20. package/dist/commands/core.js +9 -0
  21. package/dist/commands/daemon.d.ts +7 -0
  22. package/dist/commands/daemon.js +37 -3
  23. package/dist/commands/dm.js +18 -3
  24. package/dist/commands/doctor.d.ts +11 -1
  25. package/dist/commands/doctor.js +56 -1
  26. package/dist/commands/install-global.d.ts +8 -8
  27. package/dist/commands/install-global.js +139 -61
  28. package/dist/commands/lanes-shared.d.ts +3 -1
  29. package/dist/commands/lanes.d.ts +4 -4
  30. package/dist/commands/lanes.js +380 -25
  31. package/dist/commands/library-add.d.ts +31 -1
  32. package/dist/commands/library-add.js +69 -2
  33. package/dist/commands/library.js +37 -3
  34. package/dist/commands/pack-install.d.ts +2 -2
  35. package/dist/commands/pack-install.js +133 -35
  36. package/dist/commands/reindex.js +19 -0
  37. package/dist/commands/sync-manifest.d.ts +2 -9
  38. package/dist/commands/sync-manifest.js +4 -15
  39. package/dist/hook-engine-path.d.ts +4 -0
  40. package/dist/hook-engine-path.js +43 -0
  41. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2783 -2354
  42. package/dist/hq-cloud-sync-runner/package.json +1 -1
  43. package/dist/index.d.ts +10 -0
  44. package/dist/index.js +76 -5
  45. package/dist/lazy-commands.js +2 -0
  46. package/dist/lib/agent-kit/inbox-redact.d.ts +44 -0
  47. package/dist/lib/agent-kit/inbox-redact.js +361 -0
  48. package/dist/lib/agent-kit/inbox-state.js +11 -2
  49. package/dist/lib/agent-kit/mcp/tools.js +2 -1
  50. package/dist/lib/agent-kit/run/inbox.js +36 -7
  51. package/dist/lib/anywhere-runtime-flag-key.d.ts +3 -0
  52. package/dist/lib/anywhere-runtime-flag-key.js +9 -0
  53. package/dist/lib/billing/usage-cost.d.ts +31 -1
  54. package/dist/lib/billing/usage-cost.js +46 -0
  55. package/dist/lib/company-skill-wrapper.js +6 -4
  56. package/dist/lib/daemon/install.d.ts +47 -1
  57. package/dist/lib/daemon/install.js +181 -15
  58. package/dist/lib/daemon/lock.d.ts +32 -0
  59. package/dist/lib/daemon/lock.js +55 -2
  60. package/dist/lib/db/local.d.ts +2 -2
  61. package/dist/lib/db/local.js +2 -2
  62. package/dist/lib/db/sql.js +2 -2
  63. package/dist/lib/db/sqlite-binding.d.ts +22 -0
  64. package/dist/lib/db/sqlite-binding.js +168 -0
  65. package/dist/lib/doctor/__testing__/run-doctor.js +4 -0
  66. package/dist/lib/doctor/checks/anywhere.d.ts +15 -0
  67. package/dist/lib/doctor/checks/anywhere.js +77 -11
  68. package/dist/lib/doctor/checks/cli-path.d.ts +12 -0
  69. package/dist/lib/doctor/checks/cli-path.js +46 -0
  70. package/dist/lib/doctor/checks/hook-engine.d.ts +4 -0
  71. package/dist/lib/doctor/checks/hook-engine.js +17 -0
  72. package/dist/lib/doctor/checks/sync-health.d.ts +7 -0
  73. package/dist/lib/doctor/checks/sync-health.js +38 -2
  74. package/dist/lib/doctor/checks/temp-homes.d.ts +19 -0
  75. package/dist/lib/doctor/checks/temp-homes.js +54 -0
  76. package/dist/lib/doctor/fix/apply.d.ts +90 -1
  77. package/dist/lib/doctor/fix/apply.js +176 -10
  78. package/dist/lib/doctor/fix/json-output.d.ts +4 -1
  79. package/dist/lib/doctor/fix/json-output.js +6 -0
  80. package/dist/lib/doctor/fix/remediation.d.ts +7 -1
  81. package/dist/lib/doctor/fix/remediation.js +23 -1
  82. package/dist/lib/doctor/registry.js +10 -0
  83. package/dist/lib/doctor/temp-home-sweeper.d.ts +149 -0
  84. package/dist/lib/doctor/temp-home-sweeper.js +418 -0
  85. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  86. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  87. package/dist/lib/flag-registry.js +9 -1
  88. package/dist/lib/flag-snapshot-file.js +8 -3
  89. package/dist/lib/hook-engine/reconcile.d.ts +9 -0
  90. package/dist/lib/hook-engine/reconcile.js +134 -0
  91. package/dist/lib/hook-engine/registration.d.ts +17 -0
  92. package/dist/lib/hook-engine/registration.js +50 -0
  93. package/dist/lib/hook-engine/resolve.d.ts +31 -0
  94. package/dist/lib/hook-engine/resolve.js +134 -0
  95. package/dist/lib/install/claude-targets.d.ts +2 -1
  96. package/dist/lib/install/claude-targets.js +8 -3
  97. package/dist/lib/install/codex-targets.d.ts +3 -0
  98. package/dist/lib/install/codex-targets.js +15 -4
  99. package/dist/lib/install/hook-target.d.ts +9 -0
  100. package/dist/lib/install/hook-target.js +39 -0
  101. package/dist/lib/lanes/admission-host.d.ts +1 -0
  102. package/dist/lib/lanes/admission-host.js +8 -1
  103. package/dist/lib/lanes/admission.d.ts +4 -0
  104. package/dist/lib/lanes/admission.js +6 -2
  105. package/dist/lib/lanes/envelope-repair-dispatch.d.ts +5 -0
  106. package/dist/lib/lanes/envelope-repair-dispatch.js +12 -1
  107. package/dist/lib/lanes/index.d.ts +2 -2
  108. package/dist/lib/lanes/index.js +1 -1
  109. package/dist/lib/lanes/keep-alive.js +4 -1
  110. package/dist/lib/lanes/local-host-identity.d.ts +40 -0
  111. package/dist/lib/lanes/local-host-identity.js +314 -0
  112. package/dist/lib/lanes/reaper.d.ts +1 -1
  113. package/dist/lib/lanes/reaper.js +7 -0
  114. package/dist/lib/lanes/reconcile.d.ts +4 -0
  115. package/dist/lib/lanes/reconcile.js +16 -8
  116. package/dist/lib/lanes/spawn.d.ts +20 -0
  117. package/dist/lib/lanes/spawn.js +48 -2
  118. package/dist/lib/lanes/status-fields.js +6 -1
  119. package/dist/lib/lanes/store.js +8 -1
  120. package/dist/lib/lanes/throttle.d.ts +49 -6
  121. package/dist/lib/lanes/throttle.js +154 -17
  122. package/dist/lib/lanes/types.d.ts +22 -1
  123. package/dist/lib/lanes/types.js +27 -3
  124. package/dist/lib/lanes/watcher.js +27 -2
  125. package/dist/lib/lanes/worker-continuity.d.ts +2 -0
  126. package/dist/lib/lanes/worker-continuity.js +18 -11
  127. package/dist/lib/manifest-disabled.d.ts +13 -0
  128. package/dist/lib/manifest-disabled.js +19 -0
  129. package/dist/lib/service-manager/launchd.d.ts +1 -0
  130. package/dist/lib/service-manager/launchd.js +1 -1
  131. package/dist/lib/windows/console-encoding.d.ts +24 -0
  132. package/dist/lib/windows/console-encoding.js +83 -0
  133. package/dist/lib/windows/install-followup.d.ts +35 -0
  134. package/dist/lib/windows/install-followup.js +142 -0
  135. package/dist/lib/windows/user-path.d.ts +125 -0
  136. package/dist/lib/windows/user-path.js +275 -0
  137. package/dist/lib/work-context/auto-bind.d.ts +1 -1
  138. package/dist/lib/work-context/auto-bind.js +22 -3
  139. package/dist/lib/work-context/lane-bind.d.ts +31 -0
  140. package/dist/lib/work-context/lane-bind.js +101 -0
  141. package/dist/lib/work-context/organize.js +1 -0
  142. package/dist/lib/work-context/prd-sync.js +7 -0
  143. package/dist/lib/work-context/project.d.ts +36 -10
  144. package/dist/lib/work-context/project.js +130 -41
  145. package/dist/lib/work-context/reconcile.d.ts +6 -0
  146. package/dist/lib/work-context/reconcile.js +25 -5
  147. package/dist/lib/work-context/state.d.ts +6 -0
  148. package/dist/lib/work-context/state.js +29 -1
  149. package/dist/main.js +2 -1
  150. package/dist/unknown-command.d.ts +18 -0
  151. package/dist/unknown-command.js +88 -0
  152. package/dist/utils/cognito-session.d.ts +8 -0
  153. package/dist/utils/cognito-session.js +14 -2
  154. package/dist/utils/copy-tree.d.ts +61 -0
  155. package/dist/utils/copy-tree.js +249 -0
  156. package/dist/utils/environmental-error.js +7 -3
  157. package/dist/utils/hook-trust.d.ts +9 -0
  158. package/dist/utils/hook-trust.js +30 -2
  159. package/dist/utils/npm-invocation.d.ts +27 -0
  160. package/dist/utils/npm-invocation.js +54 -0
  161. package/dist/utils/qmd-native-binding-error.js +3 -1
  162. package/dist/utils/resolve-executable.d.ts +2 -0
  163. package/dist/utils/resolve-executable.js +6 -2
  164. package/dist/utils/safe-link.d.ts +50 -0
  165. package/dist/utils/safe-link.js +300 -0
  166. package/package.json +17 -5
  167. package/scripts/hook-engine-manifest.mjs +22 -0
  168. package/scripts/install-hook-engine.mjs +10 -0
@@ -20,7 +20,8 @@ import * as path from "node:path";
20
20
  import { vaultApiFetch } from "../../../utils/vault-api.js";
21
21
  import { writeComponentStatus } from "../creds.js";
22
22
  import { pendingInbox } from "../inbox-state.js";
23
- import { writePrivateFile } from "./../private-file.js";
23
+ import { redactAgentInboxItem, scrubInboxMirror, withInboxMirrorLock } from "../inbox-redact.js";
24
+ import { mkdirPrivate, writePrivateFile } from "./../private-file.js";
24
25
  import { createAgentKitInboxBackoffFlagReader } from "../inbox-poll-backoff-flag.js";
25
26
  import { buildWakeNotice, currentWakeState, fireWake, readWakeConfig, shouldWake, } from "../wake.js";
26
27
  export const SEEN_IDS_NAME = "seen-ids.json";
@@ -79,12 +80,22 @@ function inboxJournalContains(paths, id) {
79
80
  }
80
81
  export function mirrorInboxItem(paths, item) {
81
82
  const dest = path.join(paths.inboxDir, `${item.id}.json`);
82
- const recoveringInterruptedMirror = fs.existsSync(dest);
83
- writePrivateFile(dest, `${JSON.stringify(item.raw, null, 2)}\n`, 0o600);
84
- if (!recoveringInterruptedMirror || !inboxJournalContains(paths, item.id)) {
85
- fs.appendFileSync(path.join(paths.inboxDir, INBOX_JSONL_NAME), `${JSON.stringify({ id: item.id, mirroredAt: new Date().toISOString(), ...item.raw })}\n`, { mode: 0o600 });
86
- }
87
- return dest;
83
+ // One redaction covers the per-id file and the journal line. The journal
84
+ // spreads this object, so a credential left on `raw` would be written twice.
85
+ const safeRaw = redactAgentInboxItem(item.raw);
86
+ const write = () => {
87
+ const recoveringInterruptedMirror = fs.existsSync(dest);
88
+ writePrivateFile(dest, `${JSON.stringify(safeRaw, null, 2)}\n`, 0o600);
89
+ if (!recoveringInterruptedMirror || !inboxJournalContains(paths, item.id)) {
90
+ fs.appendFileSync(path.join(paths.inboxDir, INBOX_JSONL_NAME), `${JSON.stringify({ id: item.id, mirroredAt: new Date().toISOString(), ...safeRaw })}\n`, { mode: 0o600 });
91
+ }
92
+ return dest;
93
+ };
94
+ // Create the directory first so the scrub lock has somewhere to live.
95
+ // The write still runs without the lock if the directory cannot be locked;
96
+ // dropping the message is worse than a rare overlapping rewrite.
97
+ mkdirPrivate(paths.inboxDir);
98
+ return withInboxMirrorLock(paths.inboxDir, 10_000, write) ?? write();
88
99
  }
89
100
  export const INBOX_ERROR_BODY_MAX = 300;
90
101
  /**
@@ -142,6 +153,24 @@ export function defaultAckItem(agentUid, apiBaseUrl) {
142
153
  export async function pollInboxOnce(deps) {
143
154
  if (deps.signal?.aborted)
144
155
  return { ok: false, mirrored: 0 };
156
+ // Mirrors written before credential redaction are scrubbed on the next poll,
157
+ // before a wake can send the bot at the files. A write or rename error is
158
+ // counted and retried on the next poll. The log is a count and errno codes,
159
+ // never a path or a file byte.
160
+ try {
161
+ scrubInboxMirror(deps.paths, (report) => {
162
+ if (report.scrubbed > 0) {
163
+ deps.log("info", `inbox scrubbed ${report.scrubbed} mirrored record(s) containing delegated credentials`);
164
+ }
165
+ if (report.failures > 0) {
166
+ const codes = report.codes.length > 0 ? report.codes.join(",") : "ERR";
167
+ deps.log("warn", `inbox scrub failed for ${report.failures} file(s) (${codes})`);
168
+ }
169
+ });
170
+ }
171
+ catch {
172
+ deps.log("warn", "inbox scrub failed for 1 file(s) (ERR)");
173
+ }
145
174
  const fetchInbox = deps.fetchInbox ?? defaultFetchInbox(deps.agentUid, deps.apiBaseUrl);
146
175
  const ackItem = deps.ackItem ?? defaultAckItem(deps.agentUid, deps.apiBaseUrl);
147
176
  // No default cache warm: the human conversation routes 403 for agent callers.
@@ -1,6 +1,9 @@
1
1
  /** hq-flags key for the staged HQ Anywhere runtime. The registry default is on. */
2
2
  export declare const HQ_ANYWHERE_RUNTIME_FLAG = "hq-anywhere-runtime";
3
3
  export declare const HQ_ANYWHERE_RUNTIME_REVOCATION_MARKER = "hq-anywhere-runtime.revoked";
4
+ export declare const HQ_ANYWHERE_RUNTIME_FALSE_MAX_AGE_MS: number;
5
+ /** An old disable decision must not survive an unavailable registry indefinitely. */
6
+ export declare function freshAnywhereRuntimeFlags(flags: Record<string, boolean>, fetchedAt: number, now?: number): Record<string, boolean>;
4
7
  /** The HQ Anywhere environment kill switch stays compatible with managed provider wrappers. */
5
8
  export declare function hqAnywhereRuntimeLocallyDisabled(env?: NodeJS.ProcessEnv): boolean;
6
9
  //# sourceMappingURL=anywhere-runtime-flag-key.d.ts.map
@@ -1,6 +1,15 @@
1
1
  /** hq-flags key for the staged HQ Anywhere runtime. The registry default is on. */
2
2
  export const HQ_ANYWHERE_RUNTIME_FLAG = "hq-anywhere-runtime";
3
3
  export const HQ_ANYWHERE_RUNTIME_REVOCATION_MARKER = "hq-anywhere-runtime.revoked";
4
+ export const HQ_ANYWHERE_RUNTIME_FALSE_MAX_AGE_MS = 60 * 60 * 1000;
5
+ /** An old disable decision must not survive an unavailable registry indefinitely. */
6
+ export function freshAnywhereRuntimeFlags(flags, fetchedAt, now = Date.now()) {
7
+ if (flags[HQ_ANYWHERE_RUNTIME_FLAG] !== false || now - fetchedAt < HQ_ANYWHERE_RUNTIME_FALSE_MAX_AGE_MS)
8
+ return flags;
9
+ const fresh = { ...flags };
10
+ delete fresh[HQ_ANYWHERE_RUNTIME_FLAG];
11
+ return fresh;
12
+ }
4
13
  /** The HQ Anywhere environment kill switch stays compatible with managed provider wrappers. */
5
14
  export function hqAnywhereRuntimeLocallyDisabled(env = process.env) {
6
15
  const value = env.HQ_FLAG_HQ_ANYWHERE_RUNTIME;
@@ -201,14 +201,44 @@ export interface UsageReport {
201
201
  freeItems: string;
202
202
  drivers: Driver[];
203
203
  noSyncGuideUrl: string;
204
+ /** Set by `hq billing usage`: the account is on usage pricing, so meters are priced. */
205
+ onUsagePricing?: true;
204
206
  readOnly: true;
205
207
  }
208
+ /** One meter's reading with no price attached. */
209
+ export interface MeterReadingRow {
210
+ key: UsageMeterKey;
211
+ displayName: string;
212
+ /** In billed units (GB for storage). Null unless measured. */
213
+ value: number | null;
214
+ state: MeterState;
215
+ }
216
+ /**
217
+ * The report for an account that is not on usage pricing (a flat plan, or the
218
+ * server did not say). Readings only: no rates, no cost, no projected bill.
219
+ */
220
+ export interface UsageReadingsReport {
221
+ scope: UsageScope;
222
+ source: MonthUsage["source"];
223
+ /** False: flat plan, meters are not billed. Null: HQ did not say. */
224
+ onUsagePricing: false | null;
225
+ period: MonthUsage["period"];
226
+ meters: MeterReadingRow[];
227
+ storage?: StorageSplit;
228
+ /** The one line that says why no cost is shown. */
229
+ notice: string;
230
+ readOnly: true;
231
+ }
232
+ export type BillingUsageReport = UsageReport | UsageReadingsReport;
233
+ export declare function isReadingsReport(r: BillingUsageReport): r is UsageReadingsReport;
234
+ /** Readings for an account not on usage pricing. Never prices a meter. */
235
+ export declare function buildReadingsReport(usage: MonthUsage, onUsagePricing: false | null): UsageReadingsReport;
206
236
  export declare function buildReport(usage: MonthUsage, rates: {
207
237
  rates: readonly UsageMeterRate[];
208
238
  allowanceUsd: number;
209
239
  source: "published" | "bundled";
210
240
  card?: UsageCard;
211
241
  }): UsageReport;
212
- export declare function formatReport(r: UsageReport): string;
242
+ export declare function formatReport(r: BillingUsageReport): string;
213
243
  export {};
214
244
  //# sourceMappingURL=usage-cost.d.ts.map
@@ -362,6 +362,38 @@ export function findDrivers(usage, bill) {
362
362
  // ── Report ──────────────────────────────────────────────────────────────────
363
363
  /** The self-hosted / no-sync guide the usage card links (feed `signup.selfHosted.url`). */
364
364
  export const NO_SYNC_GUIDE_URL = SELF_HOSTED_GUIDE_URL;
365
+ export function isReadingsReport(r) {
366
+ return !("bill" in r);
367
+ }
368
+ function readingsNotice(scope, onUsagePricing) {
369
+ if (onUsagePricing === false) {
370
+ return scope.kind === "company"
371
+ ? `${scope.companySlug} is on a flat plan, so usage meters are not billed.`
372
+ : "Your personal account is on a flat plan, so usage meters are not billed.";
373
+ }
374
+ const who = scope.kind === "company" ? scope.companySlug : "your personal account";
375
+ return `HQ did not say whether ${who} is on usage pricing, so no costs are shown.`;
376
+ }
377
+ /** Readings for an account not on usage pricing. Never prices a meter. */
378
+ export function buildReadingsReport(usage, onUsagePricing) {
379
+ const byKey = new Map(usage.meters.map((m) => [m.key, m]));
380
+ const meters = BUNDLED_USAGE_METER_RATES.map((rate) => {
381
+ const reading = byKey.get(rate.key);
382
+ return reading && reading.state === "measured" && reading.value !== null
383
+ ? { key: rate.key, displayName: rate.displayName, value: reading.value, state: "measured" }
384
+ : { key: rate.key, displayName: rate.displayName, value: null, state: reading?.state ?? "not-available" };
385
+ });
386
+ return {
387
+ scope: usage.scope,
388
+ source: usage.source,
389
+ onUsagePricing,
390
+ period: usage.period,
391
+ meters,
392
+ ...(usage.storage ? { storage: usage.storage } : {}),
393
+ notice: readingsNotice(usage.scope, onUsagePricing),
394
+ readOnly: true,
395
+ };
396
+ }
365
397
  export function buildReport(usage, rates) {
366
398
  const bill = computeBill(usage, rates.rates, rates.allowanceUsd);
367
399
  return {
@@ -394,7 +426,21 @@ function valueText(m) {
394
426
  return `${Math.round(m.value)} vault${Math.round(m.value) === 1 ? "" : "s"}`;
395
427
  return `${Math.round(m.value)}`;
396
428
  }
429
+ function formatReadingsReport(r) {
430
+ const who = r.scope.kind === "company" ? r.scope.companySlug : "your personal HQ";
431
+ const lines = [];
432
+ lines.push(`Usage for ${who}, ${r.period.start.slice(0, 7)} (as of ${r.period.asOf.slice(0, 10)})`);
433
+ lines.push("");
434
+ const nameW = Math.max(...r.meters.map((m) => m.displayName.length));
435
+ for (const m of r.meters)
436
+ lines.push(` ${m.displayName.padEnd(nameW)} ${valueText(m)}`);
437
+ lines.push("");
438
+ lines.push(r.notice);
439
+ return lines.join("\n");
440
+ }
397
441
  export function formatReport(r) {
442
+ if (isReadingsReport(r))
443
+ return formatReadingsReport(r);
398
444
  const who = r.scope.kind === "company" ? r.scope.companySlug : "your personal HQ";
399
445
  const month = r.period.start.slice(0, 7);
400
446
  const lines = [];
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { removeEntryNoFollow } from "../utils/safe-link.js";
3
4
  const WIN32_RESERVED_CHARS = /[<>:"/\\|?*]/;
4
5
  const WIN32_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
5
6
  const LOCAL_SLUG = /^[a-z0-9][a-z0-9-]*$/;
@@ -227,7 +228,8 @@ function pruneStaleNamespacedWrappers(runtimeRoot, currentWrapperName, hqRoot, c
227
228
  if (LOCAL_SLUG.test(skillSlug) &&
228
229
  !fs.existsSync(canonicalSkill) &&
229
230
  isGeneratedNamespacedWrapper(candidate, hqRoot, companySlug)) {
230
- fs.rmSync(candidate, { recursive: true, force: false });
231
+ // A Windows wrapper is a junction; never recurse into its target.
232
+ removeEntryNoFollow(candidate);
231
233
  removeGeneratedMarker(candidate);
232
234
  }
233
235
  }
@@ -340,7 +342,7 @@ export function surfaceCompanySkill(input) {
340
342
  }
341
343
  if (previousMoved) {
342
344
  try {
343
- fs.rmSync(backupPath, { recursive: true, force: true });
345
+ removeEntryNoFollow(backupPath, { force: true });
344
346
  }
345
347
  catch {
346
348
  // The new wrapper is already committed. A hidden generated backup is
@@ -354,13 +356,13 @@ export function surfaceCompanySkill(input) {
354
356
  for (const legacyPath of [legacyBase, `${legacyBase}.md`]) {
355
357
  if (legacyPath !== wrapperPath &&
356
358
  isGeneratedLegacyEntry(legacyPath, sourceDir, skillFile)) {
357
- fs.rmSync(legacyPath, { recursive: true, force: false });
359
+ removeEntryNoFollow(legacyPath);
358
360
  }
359
361
  }
360
362
  }
361
363
  }
362
364
  catch (error) {
363
- fs.rmSync(stagingPath, { recursive: true, force: true });
365
+ removeEntryNoFollow(stagingPath, { force: true });
364
366
  throw error;
365
367
  }
366
368
  return { status: 0, wrapperPath };
@@ -16,6 +16,7 @@
16
16
  * never touch launchctl, systemctl or schtasks.
17
17
  */
18
18
  import { type RunCommand, type SleepFn } from "../service-manager/launchd.js";
19
+ import { type PidLockRecord } from "../mesh/live/daemon/pid-lock.js";
19
20
  import type { MachineType } from "./machine-type.js";
20
21
  export declare const DAEMON_LAUNCHD_LABEL = "ai.getindigo.hq-daemon";
21
22
  export declare const DAEMON_SYSTEMD_UNIT = "hq-daemon.service";
@@ -60,12 +61,28 @@ export interface DaemonUnitResult {
60
61
  running: boolean;
61
62
  error?: string;
62
63
  manualCommand?: string;
64
+ /**
65
+ * Windows: the task is registered but no new daemon process appeared within
66
+ * WINDOWS_START_WAIT_MS of `schtasks /Run`. With no interactive session the
67
+ * logon trigger starts it at the next sign-in; with one, it did not start.
68
+ * The caller decides which, since only it can check the session.
69
+ */
70
+ startPending?: true;
71
+ /** Windows: an older daemon may still be running; HQ could not verify it, so did not stop it. */
72
+ warning?: string;
63
73
  }
64
74
  export interface InstallDeps {
65
75
  run: RunCommand;
66
- /** Blocking sleep for the launchd reload wait (tests inject). */
76
+ /** Blocking sleep for the launchd reload wait and the Windows start wait (tests inject). */
67
77
  sleep?: SleepFn;
78
+ /**
79
+ * The running daemon's lock record (pid and start time), or null. Default:
80
+ * the daemon lock, when it names a live `hq daemon run` process.
81
+ */
82
+ runningDaemon?: () => PidLockRecord | null;
68
83
  }
84
+ /** How long install waits for the Windows task to start a daemon process. */
85
+ export declare const WINDOWS_START_WAIT_MS = 10000;
69
86
  export declare const ROOT_REFUSAL = "hq daemon runs its services as the machine's login user, never as root.";
70
87
  /**
71
88
  * Why the daemon must not run here as root, or null when it may. Agent kits
@@ -76,10 +93,39 @@ export declare function daemonRootRefusal(isRoot: boolean, machineType: MachineT
76
93
  /** Boxes run the daemon as a system unit; everything else per user. */
77
94
  export declare function installScopeFor(machineType: MachineType): InstallScope;
78
95
  export declare function planDaemonUnit(host: DaemonHost, options: InstallOptions): DaemonUnitPlan;
96
+ /** What to tell the user when HQ did not stop pid `pid` because it could not verify it. */
97
+ export declare function oldDaemonWarning(pid: number, reason: string): string;
79
98
  export declare function installDaemonUnit(host: DaemonHost, options: InstallOptions, deps: InstallDeps): DaemonUnitResult;
99
+ /**
100
+ * PowerShell script that lists Windows sessions as `id|state|DOMAIN\user`
101
+ * lines through WTSEnumerateSessions and WTSQuerySessionInformation. State is
102
+ * the numeric WTS_CONNECTSTATE_CLASS (0 = WTSActive), so the output does not
103
+ * depend on the display language, and it needs no query.exe (absent on
104
+ * Windows Home). Constrained Language Mode refuses Add-Type, so there the
105
+ * script prints `unavailable|<mode>` and lists nothing.
106
+ */
107
+ export declare const WINDOWS_SESSIONS_SCRIPT: string;
108
+ /** One Windows session from WINDOWS_SESSIONS_SCRIPT output. */
109
+ export interface WindowsSession {
110
+ id: number;
111
+ /** WTS_CONNECTSTATE_CLASS: 0 is WTSActive. */
112
+ state: number;
113
+ /** `DOMAIN\user`, a bare user name, or "" when nobody is signed in to it. */
114
+ user: string;
115
+ }
116
+ export declare function parseWindowsSessions(stdout: string): WindowsSession[];
117
+ /**
118
+ * Whether `user` (`DOMAIN\user` or a bare name) has an active Windows session.
119
+ * A disconnected session does not count: an interactive-token task cannot
120
+ * start in it. Returns "unavailable" when PowerShell runs in Constrained
121
+ * Language Mode, which refuses the check, and null when the check failed in
122
+ * any other way.
123
+ */
124
+ export declare function windowsInteractiveSession(run: RunCommand, user: string): boolean | null | "unavailable";
80
125
  export declare function uninstallDaemonUnit(host: DaemonHost, options: InstallOptions, deps: InstallDeps): {
81
126
  kind: DaemonUnitPlan["kind"];
82
127
  unitPath: string;
83
128
  removed: boolean;
129
+ warning?: string;
84
130
  };
85
131
  //# sourceMappingURL=install.d.ts.map
@@ -17,15 +17,20 @@
17
17
  */
18
18
  import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
- import { activateLaunchd, deactivateLaunchd, defaultRunCommand, installLaunchdPlist, launchdIsLoaded, launchdPlistPath, renderLaunchdPlist, } from "../service-manager/launchd.js";
20
+ import { activateLaunchd, blockingSleep, deactivateLaunchd, defaultRunCommand, installLaunchdPlist, launchdIsLoaded, launchdPlistPath, renderLaunchdPlist, } from "../service-manager/launchd.js";
21
21
  import { activateSystemd, deactivateSystemd, renderSystemdUserUnit, systemdUnitPath, } from "../service-manager/systemd.js";
22
22
  import { escapeXml, shellQuote } from "../service-manager/types.js";
23
23
  import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
24
24
  import { userToolDirs } from "./tools.js";
25
+ import { liveDaemonLock, verifyDaemonForKill } from "./lock.js";
26
+ import { daemonPaths } from "./paths.js";
25
27
  export const DAEMON_LAUNCHD_LABEL = "ai.getindigo.hq-daemon";
26
28
  export const DAEMON_SYSTEMD_UNIT = "hq-daemon.service";
27
29
  export const DAEMON_WINDOWS_TASK = "ai.getindigo.hq-daemon";
28
30
  export const SYSTEM_UNIT_DIR = "/etc/systemd/system";
31
+ /** How long install waits for the Windows task to start a daemon process. */
32
+ export const WINDOWS_START_WAIT_MS = 10_000;
33
+ const WINDOWS_START_POLL_MS = 500;
29
34
  export const ROOT_REFUSAL = "hq daemon runs its services as the machine's login user, never as root.";
30
35
  /**
31
36
  * Why the daemon must not run here as root, or null when it may. Agent kits
@@ -209,7 +214,39 @@ export function planDaemonUnit(host, options) {
209
214
  function commandError(what, r) {
210
215
  return new Error(`${what} failed: ${r.stderr.trim() || r.stdout.trim() || `exit ${r.status}`}`);
211
216
  }
212
- function activate(plan, host, run, sleep) {
217
+ /**
218
+ * Stop the Windows task and the recorded daemon's whole process tree. Ending
219
+ * the task stops only its root process (conhost when --headless is used);
220
+ * node and the services it runs keep going.
221
+ *
222
+ * The pid comes from the lock file, which can be stale: after the daemon
223
+ * exits, Windows may give its pid to another program. The process is checked
224
+ * immediately before the kill and killed only when it is verifiably the
225
+ * daemon. Returns a warning when a process was left running unverified.
226
+ */
227
+ function endWindowsDaemon(run, owner) {
228
+ run("schtasks.exe", ["/End", "/TN", DAEMON_WINDOWS_TASK]);
229
+ if (!owner || owner.pid === process.pid)
230
+ return undefined;
231
+ const check = verifyDaemonForKill(owner, run);
232
+ if (check.verdict === "daemon") {
233
+ run("taskkill.exe", ["/PID", String(owner.pid), "/T", "/F"]);
234
+ return undefined;
235
+ }
236
+ if (check.verdict === "gone")
237
+ return undefined;
238
+ return oldDaemonWarning(owner.pid, check.reason);
239
+ }
240
+ /** What to tell the user when HQ did not stop pid `pid` because it could not verify it. */
241
+ export function oldDaemonWarning(pid, reason) {
242
+ return [
243
+ `An older HQ daemon may still be running. HQ did not stop process ${pid} because ${reason}.`,
244
+ "To find a running HQ daemon:",
245
+ ` powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object CommandLine -match 'daemon run' | Select-Object ProcessId,CommandLine"`,
246
+ "Stop the process it lists with: taskkill /PID <ProcessId> /T /F",
247
+ ].join("\n");
248
+ }
249
+ function activate(plan, host, run, sleep, previous = null) {
213
250
  switch (plan.kind) {
214
251
  case "launchd":
215
252
  activateLaunchd(plan.unitPath, run, host.uid, { sleep });
@@ -231,13 +268,17 @@ function activate(plan, host, run, sleep) {
231
268
  const create = run("schtasks.exe", ["/Create", "/TN", DAEMON_WINDOWS_TASK, "/XML", plan.unitPath, "/F"]);
232
269
  if (create.status !== 0)
233
270
  throw commandError("schtasks /Create", create);
271
+ // `/Create /F` leaves a running instance alone and `/Run` is ignored
272
+ // while one runs, so a daemon from an earlier install would keep the old
273
+ // settings. Stop it first so `/Run` starts a new one.
274
+ const warning = previous ? endWindowsDaemon(run, previous) : undefined;
234
275
  const start = run("schtasks.exe", ["/Run", "/TN", DAEMON_WINDOWS_TASK]);
235
276
  if (start.status !== 0)
236
277
  throw commandError("schtasks /Run", start);
237
- return;
278
+ return warning;
238
279
  }
239
280
  case "manual":
240
- return;
281
+ return undefined;
241
282
  }
242
283
  }
243
284
  export function installDaemonUnit(host, options, deps) {
@@ -286,8 +327,32 @@ export function installDaemonUnit(host, options, deps) {
286
327
  else
287
328
  fs.writeFileSync(plan.unitPath, plan.rendered, { mode: plan.kind === "systemd-system" ? 0o644 : 0o600 });
288
329
  try {
289
- if (plan.kind !== "launchd")
290
- activate(plan, host, deps.run, deps.sleep);
330
+ const lockPath = daemonPaths(host.home, { HQ_DAEMON_DIR: host.daemonDir }).lockPath;
331
+ const runningDaemon = deps.runningDaemon ?? (() => liveDaemonLock(lockPath));
332
+ const previous = plan.kind === "schtasks" ? runningDaemon() : null;
333
+ const warning = plan.kind !== "launchd" ? activate(plan, host, deps.run, deps.sleep, previous) : undefined;
334
+ const warn = warning ? { warning } : {};
335
+ if (plan.kind === "schtasks") {
336
+ // schtasks /Run exits 0 even when the task cannot run: an
337
+ // InteractiveToken task with no signed-in session stays at "has not yet
338
+ // run" (267011). Report running only once a daemon started by this
339
+ // install holds the lock; a daemon from before the install does not count.
340
+ const outcome = waitForNewDaemon(runningDaemon, previous, deps.sleep ?? blockingSleep);
341
+ if (outcome === "started")
342
+ return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: true, ...warn };
343
+ if (outcome === "previous") {
344
+ return {
345
+ kind: plan.kind,
346
+ unitPath: plan.unitPath,
347
+ installed: true,
348
+ running: false,
349
+ error: `the daemon from before this install (pid ${previous?.pid}) is still running, ` +
350
+ "so the new task did not start. Run hq daemon uninstall, then hq daemon install",
351
+ ...warn,
352
+ };
353
+ }
354
+ return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: false, startPending: true, ...warn };
355
+ }
291
356
  return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: true };
292
357
  }
293
358
  catch (err) {
@@ -300,6 +365,112 @@ export function installDaemonUnit(host, options, deps) {
300
365
  };
301
366
  }
302
367
  }
368
+ /** A lock record names a different daemon than `previous` (new pid or new start time). */
369
+ function isNewDaemon(now, previous) {
370
+ return !previous || now.pid !== previous.pid || now.ts !== previous.ts;
371
+ }
372
+ /**
373
+ * Wait up to WINDOWS_START_WAIT_MS for a daemon started after `previous`.
374
+ * "previous" means the daemon from before the install still holds the lock at
375
+ * the end of the wait; "none" means no daemon is running.
376
+ */
377
+ function waitForNewDaemon(runningDaemon, previous, sleep) {
378
+ for (let waited = 0;; waited += WINDOWS_START_POLL_MS) {
379
+ const now = runningDaemon();
380
+ if (now && isNewDaemon(now, previous))
381
+ return "started";
382
+ if (waited >= WINDOWS_START_WAIT_MS)
383
+ return now ? "previous" : "none";
384
+ sleep(WINDOWS_START_POLL_MS);
385
+ }
386
+ }
387
+ /**
388
+ * PowerShell script that lists Windows sessions as `id|state|DOMAIN\user`
389
+ * lines through WTSEnumerateSessions and WTSQuerySessionInformation. State is
390
+ * the numeric WTS_CONNECTSTATE_CLASS (0 = WTSActive), so the output does not
391
+ * depend on the display language, and it needs no query.exe (absent on
392
+ * Windows Home). Constrained Language Mode refuses Add-Type, so there the
393
+ * script prints `unavailable|<mode>` and lists nothing.
394
+ */
395
+ export const WINDOWS_SESSIONS_SCRIPT = [
396
+ "$ErrorActionPreference = 'Stop'",
397
+ "$mode = [string]$ExecutionContext.SessionState.LanguageMode",
398
+ "if ($mode -ne 'FullLanguage') { 'unavailable|' + $mode; exit 0 }",
399
+ "Add-Type -TypeDefinition @'",
400
+ "using System;",
401
+ "using System.Runtime.InteropServices;",
402
+ "public static class HqWts {",
403
+ ' [DllImport("wtsapi32.dll", SetLastError = true)]',
404
+ " public static extern bool WTSEnumerateSessionsW(IntPtr server, int reserved, int version, out IntPtr info, out int count);",
405
+ ' [DllImport("wtsapi32.dll", SetLastError = true)]',
406
+ " public static extern bool WTSQuerySessionInformationW(IntPtr server, int sessionId, int infoClass, out IntPtr buffer, out int bytes);",
407
+ ' [DllImport("wtsapi32.dll")]',
408
+ " public static extern void WTSFreeMemory(IntPtr memory);",
409
+ " static string Query(int id, int infoClass) {",
410
+ " IntPtr buffer; int bytes;",
411
+ ' if (!WTSQuerySessionInformationW(IntPtr.Zero, id, infoClass, out buffer, out bytes)) return "";',
412
+ " try { return Marshal.PtrToStringUni(buffer); } finally { WTSFreeMemory(buffer); }",
413
+ " }",
414
+ " public static string[] List() {",
415
+ " IntPtr info; int count;",
416
+ ' if (!WTSEnumerateSessionsW(IntPtr.Zero, 0, 1, out info, out count)) throw new System.ComponentModel.Win32Exception();',
417
+ " try {",
418
+ " int size = 3 * IntPtr.Size;",
419
+ " string[] lines = new string[count];",
420
+ " for (int i = 0; i < count; i++) {",
421
+ " IntPtr row = new IntPtr(info.ToInt64() + (long)i * size);",
422
+ " int id = Marshal.ReadInt32(row);",
423
+ " int state = Marshal.ReadInt32(row, 2 * IntPtr.Size);",
424
+ " string user = Query(id, 5);",
425
+ " string domain = Query(id, 7);",
426
+ ' lines[i] = id + "|" + state + "|" + (domain.Length > 0 ? domain + "\\\\" + user : user);',
427
+ " }",
428
+ " return lines;",
429
+ " } finally { WTSFreeMemory(info); }",
430
+ " }",
431
+ "}",
432
+ "'@",
433
+ "[HqWts]::List() | ForEach-Object { $_ }",
434
+ ].join("\n");
435
+ const WTS_ACTIVE = 0;
436
+ export function parseWindowsSessions(stdout) {
437
+ const sessions = [];
438
+ for (const line of stdout.split(/\r?\n/)) {
439
+ const match = /^(\d+)\|(\d+)\|(.*)$/.exec(line.trim());
440
+ if (match)
441
+ sessions.push({ id: Number(match[1]), state: Number(match[2]), user: match[3].trim() });
442
+ }
443
+ return sessions;
444
+ }
445
+ /**
446
+ * Whether `user` (`DOMAIN\user` or a bare name) has an active Windows session.
447
+ * A disconnected session does not count: an interactive-token task cannot
448
+ * start in it. Returns "unavailable" when PowerShell runs in Constrained
449
+ * Language Mode, which refuses the check, and null when the check failed in
450
+ * any other way.
451
+ */
452
+ export function windowsInteractiveSession(run, user) {
453
+ const encoded = Buffer.from(WINDOWS_SESSIONS_SCRIPT, "utf16le").toString("base64");
454
+ const r = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]);
455
+ if (r.status !== 0)
456
+ return null;
457
+ if (/^unavailable\|/m.test(r.stdout.trim()))
458
+ return "unavailable";
459
+ const sessions = parseWindowsSessions(r.stdout);
460
+ if (sessions.length === 0)
461
+ return null;
462
+ const wanted = user.trim().toLowerCase();
463
+ const bare = (wanted.split("\\").pop() ?? "").trim();
464
+ if (!bare)
465
+ return null;
466
+ return sessions.some((s) => {
467
+ if (s.state !== WTS_ACTIVE || !s.user)
468
+ return false;
469
+ const name = s.user.toLowerCase();
470
+ // Compare the full DOMAIN\user when both sides have a domain.
471
+ return name.includes("\\") && wanted.includes("\\") ? name === wanted : name.split("\\").pop() === bare;
472
+ });
473
+ }
303
474
  export function uninstallDaemonUnit(host, options, deps) {
304
475
  // A system uninstall does not need the login user; only the unit path matters.
305
476
  const plan = host.platform === "linux" && options.scope === "system"
@@ -312,6 +483,7 @@ export function uninstallDaemonUnit(host, options, deps) {
312
483
  (plan.kind === "schtasks" && deps.run("schtasks.exe", ["/Query", "/TN", DAEMON_WINDOWS_TASK]).status === 0);
313
484
  if (!present)
314
485
  return { kind: plan.kind, unitPath: plan.unitPath, removed: false };
486
+ let warning;
315
487
  switch (plan.kind) {
316
488
  case "launchd":
317
489
  deactivateLaunchd(plan.unitPath, deps.run, host.uid);
@@ -330,14 +502,8 @@ export function uninstallDaemonUnit(host, options, deps) {
330
502
  break;
331
503
  }
332
504
  case "schtasks": {
333
- deps.run("schtasks.exe", ["/End", "/TN", DAEMON_WINDOWS_TASK]);
334
- // Ending the task stops only its root process (conhost when --headless is
335
- // used); node and the services it runs keep going. End the recorded
336
- // daemon's whole process tree.
337
- const owner = readPidLock(path.join(host.daemonDir, "daemon.pid"));
338
- if (owner && owner.pid !== process.pid) {
339
- deps.run("taskkill.exe", ["/PID", String(owner.pid), "/T", "/F"]);
340
- }
505
+ // The lock may be stale; endWindowsDaemon kills only a verified daemon.
506
+ warning = endWindowsDaemon(deps.run, readPidLock(path.join(host.daemonDir, "daemon.pid")));
341
507
  const del = deps.run("schtasks.exe", ["/Delete", "/TN", DAEMON_WINDOWS_TASK, "/F"]);
342
508
  // Keep the XML so a later uninstall still finds the task.
343
509
  if (del.status !== 0)
@@ -346,6 +512,6 @@ export function uninstallDaemonUnit(host, options, deps) {
346
512
  }
347
513
  }
348
514
  fs.rmSync(plan.unitPath, { force: true });
349
- return { kind: plan.kind, unitPath: plan.unitPath, removed: true };
515
+ return { kind: plan.kind, unitPath: plan.unitPath, removed: true, ...(warning ? { warning } : {}) };
350
516
  }
351
517
  //# sourceMappingURL=install.js.map
@@ -31,8 +31,38 @@ export interface DaemonProcessDeps {
31
31
  * The pid is alive and is an `hq daemon run`. After a reboot the pid in an old
32
32
  * lock file often belongs to an unrelated program; that must not keep the
33
33
  * daemon from starting.
34
+ *
35
+ * An unreadable command line counts as the daemon here, so a second daemon is
36
+ * never started next to one HQ cannot inspect. Never use this to decide a
37
+ * kill: use `verifyDaemonForKill`, which treats an unreadable command line as
38
+ * "not the daemon".
34
39
  */
35
40
  export declare function isDaemonProcess(pid: number, deps?: DaemonProcessDeps): boolean;
41
+ /**
42
+ * PowerShell that prints a Windows process's creation time (UTC, ISO 8601) on
43
+ * the first line and its command line on the rest, or nothing when no process
44
+ * has that id. It uses only a cmdlet and DateTime methods, so it also runs in
45
+ * Constrained Language Mode. `-ErrorAction Stop` makes a WMI failure exit
46
+ * non-zero, so it reads as "could not read", never as "no such process".
47
+ */
48
+ export declare function windowsProcessIdentityScript(pid: number): string;
49
+ export type DaemonKillCheck = {
50
+ verdict: "daemon";
51
+ } | {
52
+ verdict: "gone";
53
+ } | {
54
+ verdict: "unverified";
55
+ reason: string;
56
+ };
57
+ /**
58
+ * Decide whether the Windows process in `owner` may be killed as the HQ
59
+ * daemon. Run this immediately before the kill. It is the daemon only when the
60
+ * process exists, its command line is readable and is `hq daemon run`, and it
61
+ * was created no later than the lock was written. A process created after the
62
+ * lock took over a pid the old daemon left behind. Anything that cannot be
63
+ * read is "unverified", and the caller must not kill.
64
+ */
65
+ export declare function verifyDaemonForKill(owner: PidLockRecord, run: RunCommand): DaemonKillCheck;
36
66
  export type DaemonLockResult = {
37
67
  ok: true;
38
68
  } | {
@@ -41,6 +71,8 @@ export type DaemonLockResult = {
41
71
  };
42
72
  export declare function acquireDaemonLock(lockPath: string, pid?: number, alive?: (pid: number) => boolean, now?: () => number): DaemonLockResult;
43
73
  export declare function releaseDaemonLock(lockPath: string, pid?: number): void;
74
+ /** The live supervisor's lock record (pid and start time), or null when none is running. */
75
+ export declare function liveDaemonLock(lockPath: string, alive?: (pid: number) => boolean): PidLockRecord | null;
44
76
  /** The live supervisor's pid, or null when none is running. */
45
77
  export declare function runningDaemonPid(lockPath: string, alive?: (pid: number) => boolean): number | null;
46
78
  export {};