@indigoai-us/hq-cli 5.345.146 → 5.345.148

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 (138) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +14 -0
  3. package/assets/bot-workers/setup/worker.yaml +11 -0
  4. package/dist/command-catalog.generated.d.ts +326 -4
  5. package/dist/command-catalog.generated.js +403 -4
  6. package/dist/commands/agent-inbox.js +2 -1
  7. package/dist/commands/auth.js +4 -2
  8. package/dist/commands/bot.js +1 -1
  9. package/dist/commands/cloud.js +31 -1
  10. package/dist/commands/core-refresh-vault-access-native.d.ts +17 -0
  11. package/dist/commands/core-refresh-vault-access-native.js +214 -19
  12. package/dist/commands/core-work-mesh-bind-trusted-native.d.ts +9 -0
  13. package/dist/commands/core-work-mesh-bind-trusted-native.js +26 -19
  14. package/dist/commands/core.js +6 -0
  15. package/dist/commands/daemon.d.ts +7 -0
  16. package/dist/commands/daemon.js +37 -3
  17. package/dist/commands/dm.js +9 -2
  18. package/dist/commands/doctor.d.ts +11 -1
  19. package/dist/commands/doctor.js +56 -1
  20. package/dist/commands/files.js +10 -6
  21. package/dist/commands/install-global.d.ts +7 -0
  22. package/dist/commands/install-global.js +129 -35
  23. package/dist/commands/lanes-shared.d.ts +3 -1
  24. package/dist/commands/lanes.d.ts +4 -4
  25. package/dist/commands/lanes.js +380 -25
  26. package/dist/commands/login.js +4 -3
  27. package/dist/commands/pack-install.d.ts +2 -2
  28. package/dist/commands/pack-install.js +133 -35
  29. package/dist/commands/sync-manifest.d.ts +2 -9
  30. package/dist/commands/sync-manifest.js +4 -15
  31. package/dist/hook-engine-path.d.ts +4 -0
  32. package/dist/hook-engine-path.js +36 -0
  33. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2784 -2360
  34. package/dist/hq-cloud-sync-runner/package.json +1 -1
  35. package/dist/index.js +6 -0
  36. package/dist/lib/agent-kit/inbox-redact.d.ts +44 -0
  37. package/dist/lib/agent-kit/inbox-redact.js +361 -0
  38. package/dist/lib/agent-kit/inbox-state.js +11 -2
  39. package/dist/lib/agent-kit/mcp/tools.js +2 -1
  40. package/dist/lib/agent-kit/run/inbox.js +36 -7
  41. package/dist/lib/anywhere-runtime-flag-key.d.ts +3 -0
  42. package/dist/lib/anywhere-runtime-flag-key.js +9 -0
  43. package/dist/lib/bot/daemon.js +3 -0
  44. package/dist/lib/bot/runtime/index.js +3 -1
  45. package/dist/lib/company-skill-wrapper.js +6 -4
  46. package/dist/lib/daemon/install.d.ts +47 -1
  47. package/dist/lib/daemon/install.js +181 -15
  48. package/dist/lib/daemon/lock.d.ts +32 -0
  49. package/dist/lib/daemon/lock.js +55 -2
  50. package/dist/lib/doctor/__testing__/run-doctor.js +4 -0
  51. package/dist/lib/doctor/checks/anywhere.d.ts +3 -0
  52. package/dist/lib/doctor/checks/anywhere.js +23 -8
  53. package/dist/lib/doctor/checks/cli-path.d.ts +11 -0
  54. package/dist/lib/doctor/checks/cli-path.js +45 -0
  55. package/dist/lib/doctor/checks/hook-engine.d.ts +4 -0
  56. package/dist/lib/doctor/checks/hook-engine.js +16 -0
  57. package/dist/lib/doctor/checks/release-links.d.ts +23 -0
  58. package/dist/lib/doctor/checks/release-links.js +152 -0
  59. package/dist/lib/doctor/checks/sync-health.d.ts +7 -0
  60. package/dist/lib/doctor/checks/sync-health.js +38 -2
  61. package/dist/lib/doctor/checks/temp-homes.d.ts +19 -0
  62. package/dist/lib/doctor/checks/temp-homes.js +54 -0
  63. package/dist/lib/doctor/fix/apply.d.ts +90 -1
  64. package/dist/lib/doctor/fix/apply.js +176 -10
  65. package/dist/lib/doctor/fix/json-output.d.ts +4 -1
  66. package/dist/lib/doctor/fix/json-output.js +6 -0
  67. package/dist/lib/doctor/fix/remediation.d.ts +7 -1
  68. package/dist/lib/doctor/fix/remediation.js +23 -1
  69. package/dist/lib/doctor/registry.js +13 -0
  70. package/dist/lib/doctor/temp-home-sweeper.d.ts +149 -0
  71. package/dist/lib/doctor/temp-home-sweeper.js +418 -0
  72. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  73. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  74. package/dist/lib/flag-registry.js +9 -1
  75. package/dist/lib/flag-snapshot-file.js +8 -3
  76. package/dist/lib/hook-engine/resolve.d.ts +31 -0
  77. package/dist/lib/hook-engine/resolve.js +134 -0
  78. package/dist/lib/lanes/admission-host.d.ts +1 -0
  79. package/dist/lib/lanes/admission-host.js +8 -1
  80. package/dist/lib/lanes/admission.d.ts +4 -0
  81. package/dist/lib/lanes/admission.js +6 -2
  82. package/dist/lib/lanes/envelope-repair-dispatch.d.ts +5 -0
  83. package/dist/lib/lanes/envelope-repair-dispatch.js +12 -1
  84. package/dist/lib/lanes/index.d.ts +2 -2
  85. package/dist/lib/lanes/index.js +1 -1
  86. package/dist/lib/lanes/keep-alive.js +4 -1
  87. package/dist/lib/lanes/local-host-identity.d.ts +40 -0
  88. package/dist/lib/lanes/local-host-identity.js +314 -0
  89. package/dist/lib/lanes/reaper.d.ts +1 -1
  90. package/dist/lib/lanes/reaper.js +7 -0
  91. package/dist/lib/lanes/reconcile.d.ts +4 -0
  92. package/dist/lib/lanes/reconcile.js +16 -8
  93. package/dist/lib/lanes/spawn.d.ts +20 -0
  94. package/dist/lib/lanes/spawn.js +48 -2
  95. package/dist/lib/lanes/status-fields.js +6 -1
  96. package/dist/lib/lanes/store.js +8 -1
  97. package/dist/lib/lanes/throttle.d.ts +49 -6
  98. package/dist/lib/lanes/throttle.js +154 -17
  99. package/dist/lib/lanes/types.d.ts +22 -1
  100. package/dist/lib/lanes/types.js +27 -3
  101. package/dist/lib/lanes/watcher.js +27 -2
  102. package/dist/lib/lanes/worker-continuity.d.ts +2 -0
  103. package/dist/lib/lanes/worker-continuity.js +18 -11
  104. package/dist/lib/manifest-disabled.d.ts +13 -0
  105. package/dist/lib/manifest-disabled.js +19 -0
  106. package/dist/lib/service-manager/launchd.d.ts +1 -0
  107. package/dist/lib/service-manager/launchd.js +1 -1
  108. package/dist/lib/windows/user-path.d.ts +48 -0
  109. package/dist/lib/windows/user-path.js +114 -0
  110. package/dist/lib/work-context/auto-bind.d.ts +1 -1
  111. package/dist/lib/work-context/auto-bind.js +22 -3
  112. package/dist/lib/work-context/lane-bind.d.ts +31 -0
  113. package/dist/lib/work-context/lane-bind.js +101 -0
  114. package/dist/lib/work-context/organize.js +1 -0
  115. package/dist/lib/work-context/prd-sync.js +7 -0
  116. package/dist/lib/work-context/project.d.ts +36 -10
  117. package/dist/lib/work-context/project.js +130 -41
  118. package/dist/lib/work-context/reconcile.d.ts +6 -0
  119. package/dist/lib/work-context/reconcile.js +25 -5
  120. package/dist/lib/work-context/state.d.ts +6 -0
  121. package/dist/lib/work-context/state.js +29 -1
  122. package/dist/lib/workers-registry/index.d.ts +6 -0
  123. package/dist/lib/workers-registry/index.js +8 -0
  124. package/dist/utils/copy-tree.d.ts +61 -0
  125. package/dist/utils/copy-tree.js +249 -0
  126. package/dist/utils/environmental-error.js +7 -3
  127. package/dist/utils/headless.d.ts +2 -0
  128. package/dist/utils/headless.js +8 -0
  129. package/dist/utils/login-browser.d.ts +4 -0
  130. package/dist/utils/login-browser.js +122 -0
  131. package/dist/utils/npm-invocation.d.ts +27 -0
  132. package/dist/utils/npm-invocation.js +54 -0
  133. package/dist/utils/safe-link.d.ts +50 -0
  134. package/dist/utils/safe-link.js +300 -0
  135. package/dist/utils/team-upgrade.js +1 -7
  136. package/package.json +17 -5
  137. package/scripts/hook-engine-manifest.mjs +22 -0
  138. package/scripts/install-hook-engine.mjs +10 -0
@@ -1,6 +1,8 @@
1
1
  /** Native local manifest refresh for core/scripts/refresh-vault-access.sh. */
2
2
  import { spawnSync } from "node:child_process";
3
+ import { randomBytes } from "node:crypto";
3
4
  import * as fs from "node:fs";
5
+ import * as os from "node:os";
4
6
  import * as path from "node:path";
5
7
  import { resolveWindowsExecutable } from "../lib/bot/runtime/windows.js";
6
8
  import { serializeJqJson } from "../lib/core-utils/jq-json-serializer.js";
@@ -36,6 +38,11 @@ export const REFRESH_VAULT_ACCESS_HELP = `# hq-core: public
36
38
  # the authoritative security boundary — this manifest only powers the local
37
39
  # early-warning block for read-only shares.
38
40
  #
41
+ # Scope: with no --company the roll-up rewrites the whole manifest. With
42
+ # --company, only the named companies' entries are replaced; every other
43
+ # company's entry is kept as it was. Writes are atomic and serialized by a
44
+ # lock file next to the manifest.
45
+ #
39
46
  # Usage:
40
47
  # bash core/scripts/refresh-vault-access.sh [--company <slug>]... [--root <hqRoot>]
41
48
  #
@@ -189,8 +196,13 @@ export function runRefreshVaultAccess(args, deps) {
189
196
  const runHq = deps.runHq ?? ((hqArgs, cwd) => defaultHq(hqArgs, cwd, deps.env));
190
197
  const identity = resolveEmail(deps, runHq);
191
198
  let stderr = identity.stderr;
192
- const explicit = parsed.slugs;
199
+ const explicit = [...new Set(parsed.slugs)];
193
200
  const allGrants = new Map();
201
+ // Companies whose entry this run replaces. A scoped run (--company) owns only
202
+ // the companies it queried successfully; every other company already in the
203
+ // manifest is carried over untouched. The unscoped roll-up owns the whole
204
+ // manifest, so a company that lost all its grants drops out of it.
205
+ const refreshed = new Set();
194
206
  if (explicit.length > 0) {
195
207
  for (const slug of explicit) {
196
208
  const result = runHq(["files", "shared-with-me", "--company", slug], deps.cwd);
@@ -198,45 +210,228 @@ export function runRefreshVaultAccess(args, deps) {
198
210
  stderr += log(`skipping ${slug} — shared-with-me query failed`);
199
211
  continue;
200
212
  }
201
- const rows = parseGrantTable(result.stdout);
202
- for (const [company, grants] of rows)
203
- allGrants.set(company, [...(allGrants.get(company) ?? []), ...grants]);
213
+ refreshed.add(slug);
214
+ const grants = parseGrantTable(result.stdout).get(slug) ?? [];
215
+ allGrants.set(slug, [...(allGrants.get(slug) ?? []), ...grants]);
204
216
  }
205
217
  }
206
218
  else {
207
219
  const result = runHq(["files", "shared-with-me"], deps.cwd);
208
220
  if (result.code !== 0)
209
221
  return { code: 0, stdout: "", stderr: `${stderr}${log("shared-with-me roll-up failed — nothing refreshed")}` };
210
- for (const [company, grants] of parseGrantTable(result.stdout))
222
+ for (const [company, grants] of parseGrantTable(result.stdout)) {
211
223
  allGrants.set(company, grants);
224
+ refreshed.add(company);
225
+ }
212
226
  }
213
- const companies = new Set(allGrants.keys());
214
- for (const slug of explicit)
215
- companies.add(slug);
216
- const sortedSlugs = [...companies].sort();
217
- if (sortedSlugs.length === 0)
227
+ if (explicit.length > 0 && refreshed.size === 0)
228
+ return { code: 0, stdout: "", stderr: `${stderr}${log("no requested company could be queried — nothing refreshed")}` };
229
+ if (explicit.length === 0 && refreshed.size === 0)
218
230
  stderr += log("no explicit grants found — writing an empty manifest (hook stays fail-open everywhere)");
219
- const companyData = {};
220
- for (const slug of sortedSlugs) {
231
+ const fresh = new Map();
232
+ for (const slug of [...refreshed].sort()) {
221
233
  let role = "unknown";
222
234
  if (identity.email) {
223
235
  const result = runHq(["members", "--company", slug, "list"], deps.cwd);
224
236
  role = result.code === 0 ? roleFromTable(result.stdout, identity.email) : "unknown";
225
237
  }
226
238
  const grants = (allGrants.get(slug) ?? []).map((grant) => ({ path: grant.path, permission: grant.permission }));
227
- companyData[slug] = { role, grants };
239
+ fresh.set(slug, { role, grants });
228
240
  }
229
- const manifest = { version: 1, generatedAt: deps.now().toISOString().replace(/\.\d{3}Z$/, "Z"), companies: companyData };
230
241
  const directory = path.join(root, ".hq");
231
242
  fs.mkdirSync(directory, { recursive: true });
232
243
  const target = path.join(directory, "vault-access.json");
233
- const temp = `${target}.${process.pid}.${Math.random().toString(16).slice(2)}`;
234
- fs.writeFileSync(temp, serializeJqJson(manifest, "pretty"), { encoding: "utf8", mode: 0o600, flag: "wx" });
235
- fs.chmodSync(temp, 0o600);
236
- fs.renameSync(temp, target);
237
- stderr += log(`wrote .hq/vault-access.json (${sortedSlugs.length} companies, identity: ${identity.email || "unknown"})`);
244
+ // Read the current manifest only once the lock is held, after the slow hq
245
+ // queries, so a refresh for another company that finished meanwhile is kept.
246
+ const written = withManifestLock(target, deps.lockWaitMs ?? LOCK_WAIT_MS, (stillOwned) => {
247
+ // Company slugs come from --company and from the manifest, so they are
248
+ // kept in a Map and a null-prototype object; "__proto__" stays a plain key.
249
+ const companyData = new Map();
250
+ if (explicit.length > 0) {
251
+ const existing = readCompanies(target);
252
+ stderr += existing.warning;
253
+ for (const [slug, entry] of Object.entries(existing.companies))
254
+ if (!refreshed.has(slug))
255
+ companyData.set(slug, entry);
256
+ }
257
+ for (const [slug, entry] of fresh)
258
+ companyData.set(slug, entry);
259
+ const sorted = Object.create(null);
260
+ for (const slug of [...companyData.keys()].sort())
261
+ sorted[slug] = companyData.get(slug);
262
+ const manifest = { version: 1, generatedAt: deps.now().toISOString().replace(/\.\d{3}Z$/, "Z"), companies: sorted };
263
+ const temp = `${target}.${process.pid}.${randomBytes(8).toString("hex")}`;
264
+ fs.writeFileSync(temp, serializeJqJson(manifest, "pretty"), { encoding: "utf8", mode: 0o600, flag: "wx" });
265
+ try {
266
+ fs.chmodSync(temp, 0o600);
267
+ // A refresh that held the lock past the stale threshold may have been
268
+ // taken over; it must not overwrite the new holder's merge.
269
+ if (!stillOwned()) {
270
+ fs.rmSync(temp, { force: true });
271
+ return undefined;
272
+ }
273
+ fs.renameSync(temp, target);
274
+ }
275
+ catch (err) {
276
+ fs.rmSync(temp, { force: true });
277
+ throw err;
278
+ }
279
+ return companyData.size;
280
+ });
281
+ if (written === undefined)
282
+ return { code: 0, stdout: "", stderr: `${stderr}${log("another refresh is still writing .hq/vault-access.json — nothing refreshed")}` };
283
+ const scope = explicit.length > 0 ? `, refreshed: ${[...refreshed].sort().join(", ")}` : "";
284
+ stderr += log(`wrote .hq/vault-access.json (${written} companies${scope}, identity: ${identity.email || "unknown"})`);
238
285
  return { code: 0, stdout: "", stderr };
239
286
  }
287
+ /** Company entries already in the manifest; empty, with a warning, when it is missing or unreadable. */
288
+ function readCompanies(target) {
289
+ let text;
290
+ try {
291
+ text = fs.readFileSync(target, "utf8");
292
+ }
293
+ catch (err) {
294
+ const code = err.code;
295
+ return { companies: {}, warning: code === "ENOENT" ? "" : log(`could not read the existing manifest (${code ?? "error"}) — other companies' entries are not carried over`) };
296
+ }
297
+ try {
298
+ const companies = JSON.parse(text)?.companies;
299
+ if (companies && typeof companies === "object" && !Array.isArray(companies))
300
+ return { companies: companies, warning: "" };
301
+ }
302
+ catch { /* reported below */ }
303
+ return { companies: {}, warning: log("the existing manifest is not valid — other companies' entries are not carried over") };
304
+ }
305
+ /** A lock older than this belongs to a refresh that died holding it. */
306
+ const LOCK_STALE_MS = 30_000;
307
+ const LOCK_WAIT_MS = 5_000;
308
+ function sleepSync(ms) {
309
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
310
+ }
311
+ function readLockToken(lock) {
312
+ try {
313
+ return fs.readFileSync(lock, "utf8");
314
+ }
315
+ catch {
316
+ return undefined;
317
+ }
318
+ }
319
+ /** True when the token names a process on this host that no longer exists. */
320
+ function holderIsGone(token) {
321
+ const match = /^(\d+):(.*):[0-9a-f]+$/.exec(token);
322
+ if (!match || match[2] !== os.hostname())
323
+ return false;
324
+ const pid = Number(match[1]);
325
+ if (pid === process.pid)
326
+ return false;
327
+ try {
328
+ process.kill(pid, 0);
329
+ return false;
330
+ }
331
+ catch (err) {
332
+ return err.code === "ESRCH";
333
+ }
334
+ }
335
+ /**
336
+ * Remove the lock only if it still carries `expected`. The lock is first
337
+ * renamed to a name private to this caller, which only one process can do for
338
+ * a given lock file. If the moved file is not the expected lock (another
339
+ * process replaced it in the meantime), it is linked back under the lock name
340
+ * unchanged.
341
+ */
342
+ function removeLockIf(lock, expected, id) {
343
+ const moved = `${lock}.${id}.moved`;
344
+ try {
345
+ fs.renameSync(lock, moved);
346
+ }
347
+ catch {
348
+ return false;
349
+ }
350
+ const ours = readLockToken(moved) === expected;
351
+ if (!ours) {
352
+ try {
353
+ fs.linkSync(moved, lock);
354
+ }
355
+ catch { /* a newer lock already exists; leave it */ }
356
+ }
357
+ fs.rmSync(moved, { force: true });
358
+ return ours;
359
+ }
360
+ /**
361
+ * Take the manifest lock and return its owner token, or undefined when the
362
+ * lock stays busy past `waitMs`. The lock file is created by hard-linking a
363
+ * file that already holds the token, so it never exists without an owner. A
364
+ * stale lock (older than LOCK_STALE_MS, or whose holder on this host has
365
+ * exited) is taken over through removeLockIf, so two waiters that both judge
366
+ * it stale cannot remove each other's new lock.
367
+ */
368
+ export function acquireManifestLock(lock, waitMs, hooks = {}) {
369
+ const nonce = randomBytes(8).toString("hex");
370
+ const id = `${process.pid}.${nonce}`;
371
+ const token = `${process.pid}:${os.hostname()}:${nonce}`;
372
+ const pending = `${lock}.${id}.new`;
373
+ const deadline = Date.now() + Math.max(0, waitMs);
374
+ fs.writeFileSync(pending, token, { flag: "wx", mode: 0o600 });
375
+ try {
376
+ for (;;) {
377
+ try {
378
+ fs.linkSync(pending, lock);
379
+ return token;
380
+ }
381
+ catch (err) {
382
+ if (err.code !== "EEXIST")
383
+ throw err;
384
+ }
385
+ const held = readLockToken(lock);
386
+ let age;
387
+ // lstat, so a lock name that is a dangling link still counts as held.
388
+ try {
389
+ age = Date.now() - fs.lstatSync(lock).mtimeMs;
390
+ }
391
+ catch {
392
+ continue;
393
+ }
394
+ if (held !== undefined && (age > LOCK_STALE_MS || holderIsGone(held))) {
395
+ hooks.afterStaleCheck?.();
396
+ if (removeLockIf(lock, held, id))
397
+ continue;
398
+ }
399
+ // Every path that did not just free the lock waits and honours the
400
+ // deadline, so a lock that cannot be read or taken over never spins.
401
+ if (Date.now() >= deadline)
402
+ return undefined;
403
+ sleepSync(25);
404
+ }
405
+ }
406
+ finally {
407
+ fs.rmSync(pending, { force: true });
408
+ }
409
+ }
410
+ /** Release the lock if `token` still owns it. Returns false when another process has taken it over. */
411
+ export function releaseManifestLock(lock, token) {
412
+ if (readLockToken(lock) !== token)
413
+ return false;
414
+ return removeLockIf(lock, token, `${process.pid}.${randomBytes(8).toString("hex")}`);
415
+ }
416
+ /**
417
+ * Serialize the read-merge-write of the manifest across processes. Without it
418
+ * two scoped refreshes for different companies can each read the old file and
419
+ * the second rename drops the first one's company. `fn` gets a check that the
420
+ * lock is still held, to call just before it commits. Returns undefined when
421
+ * the lock stays busy past `waitMs`.
422
+ */
423
+ function withManifestLock(target, waitMs, fn) {
424
+ const lock = `${target}.lock`;
425
+ const token = acquireManifestLock(lock, waitMs);
426
+ if (token === undefined)
427
+ return undefined;
428
+ try {
429
+ return fn(() => readLockToken(lock) === token);
430
+ }
431
+ finally {
432
+ releaseManifestLock(lock, token);
433
+ }
434
+ }
240
435
  export function registerCoreRefreshVaultAccessNative(core) {
241
436
  registerNativeUtility(core, {
242
437
  name: "refresh-vault-access", root: "cwd", summary: "Refresh the local vault access manifest",
@@ -45,6 +45,15 @@ type DetachedSpawnOptions = {
45
45
  };
46
46
  };
47
47
  export declare function spawnWorkMeshHqDetached(args: string[], options: DetachedSpawnOptions): void;
48
+ /**
49
+ * Resolve the bind session. Only a session id the caller owns (the process env)
50
+ * is trusted; workspace/sessions/.current is last-writer-wins and can name
51
+ * another session, so it is a fallback that never carries trusted authority.
52
+ */
53
+ export declare function resolveWorkMeshBindSession(root: string, env: NodeJS.ProcessEnv, fileSystem?: typeof fs): {
54
+ sid: string;
55
+ trusted: boolean;
56
+ };
48
57
  export declare function resolveWorkMeshBindSessionId(root: string, env: NodeJS.ProcessEnv, fileSystem?: typeof fs): string;
49
58
  export declare function runWorkMeshBindTrusted(args: string[], deps: WorkMeshBindDeps): UtilityOutput;
50
59
  export declare function registerCoreWorkMeshBindTrustedNative(core: Command): void;
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { spawn, spawnSync } from "node:child_process";
5
5
  import { resolveWindowsExecutable } from "../lib/bot/runtime/windows.js";
6
+ import { sessionIdFromEnv, validSessionId } from "../lib/core-utils/session-id.js";
6
7
  import { generateUlid } from "../lib/mesh/live/ulid.js";
7
8
  import { resolveLiveRoot } from "../utils/hq-roots.js";
8
9
  import { planResolvedCommandSpawn } from "../utils/windows-spawn.js";
@@ -72,20 +73,26 @@ export function spawnWorkMeshHqDetached(args, options) {
72
73
  });
73
74
  child.unref();
74
75
  }
75
- export function resolveWorkMeshBindSessionId(root, env, fileSystem = fs) {
76
- for (const key of ["HQ_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID", "CODEX_SESSION_ID", "CODEX_THREAD_ID"]) {
77
- const value = (env[key] ?? "").replace(/\s/g, "");
78
- if (value)
79
- return value;
80
- }
76
+ /**
77
+ * Resolve the bind session. Only a session id the caller owns (the process env)
78
+ * is trusted; workspace/sessions/.current is last-writer-wins and can name
79
+ * another session, so it is a fallback that never carries trusted authority.
80
+ */
81
+ export function resolveWorkMeshBindSession(root, env, fileSystem = fs) {
82
+ const fromEnv = sessionIdFromEnv(env);
83
+ if (fromEnv)
84
+ return { sid: fromEnv, trusted: true };
81
85
  try {
82
- const value = fileSystem.readFileSync(path.join(root, "workspace/sessions/.current"), "utf8").replace(/\s/g, "");
83
- return value;
86
+ const value = fileSystem.readFileSync(path.join(root, "workspace/sessions/.current"), "utf8").replace(/[\t\n\v\f\r ]/g, "");
87
+ return { sid: validSessionId(value) ? value : "", trusted: false };
84
88
  }
85
89
  catch {
86
- return "";
90
+ return { sid: "", trusted: false };
87
91
  }
88
92
  }
93
+ export function resolveWorkMeshBindSessionId(root, env, fileSystem = fs) {
94
+ return resolveWorkMeshBindSession(root, env, fileSystem).sid;
95
+ }
89
96
  function hasExecutable(deps, command) {
90
97
  if (process.platform === "win32") {
91
98
  return Boolean(resolveWindowsExecutable(command, deps.env, (candidate) => {
@@ -158,17 +165,15 @@ export function runWorkMeshBindTrusted(args, deps) {
158
165
  else
159
166
  i += 1;
160
167
  }
161
- if (!sid) {
162
- sid = resolveWorkMeshBindSessionId(root, deps.env, deps.fs);
163
- if (!sid) {
164
- try {
165
- sid = deps.fs.readFileSync(path.join(root, "workspace/sessions/.current"), "utf8").replace(/\s/g, "");
166
- }
167
- catch {
168
- sid = "";
169
- }
170
- }
168
+ // Trusted authority needs a session id the caller owns: --session or the
169
+ // process env. An invalid --session stops the bind, so it cannot fall back to
170
+ // whichever session .current names.
171
+ if (sid && !validSessionId(sid)) {
172
+ return { code: 2, stdout: "", stderr: "work-mesh-live-bind-trusted: invalid --session id\n" };
171
173
  }
174
+ let sessionTrusted = Boolean(sid);
175
+ if (!sid)
176
+ ({ sid, trusted: sessionTrusted } = resolveWorkMeshBindSession(root, deps.env, deps.fs));
172
177
  const childEnv = { ...deps.env, HQ_ROOT: root };
173
178
  const pin = sid ? ["--session-id", sid] : [];
174
179
  let stderr = "";
@@ -207,6 +212,8 @@ export function runWorkMeshBindTrusted(args, deps) {
207
212
  trusted.project = project;
208
213
  if (task)
209
214
  trusted.task = task;
215
+ if (project && sessionTrusted)
216
+ trusted.authority = "trusted";
210
217
  observation.trustedContext = trusted;
211
218
  }
212
219
  const directory = path.join(deps.tmpDir, "hq-work-mesh-obs");
@@ -30,6 +30,7 @@
30
30
  */
31
31
  import { Option } from "commander";
32
32
  import { registerCoreCheckpointCommand } from "./core-checkpoint.js";
33
+ import { runHookEnginePath } from '../hook-engine-path.js';
33
34
  import { registerCoreDelegateCommand } from "./core-delegate.js";
34
35
  import { registerCoreDelegateW1b } from "./core-delegate-w1b.js";
35
36
  import { registerCoreHandoffCommand } from "./core-handoff.js";
@@ -439,6 +440,11 @@ export function registerCoreCommands(program) {
439
440
  // This group primarily hosts manifest-driven bundled assets, but it also
440
441
  // hosts native TypeScript plumbing when a scaffold contract needs it.
441
442
  registerCoreCheckpointCommand(core);
443
+ const hookEnginePath = core.command('hook-engine-path')
444
+ .description('Print the offline, version-pinned native hook engine path')
445
+ .option('--repair', 'Repair the pinned copy after closing running hook processes')
446
+ .action((options) => runHookEnginePath(options.repair ? ['--repair'] : []));
447
+ describeCoreCommand(hookEnginePath, { execution: 'native', root: 'cwd' });
442
448
  registerCoreOntologyCommand(core);
443
449
  registerCoreCheckpointGateSettingCommand(core);
444
450
  registerCoreDelegateCommand(core, registerCoreDelegateW1b);
@@ -45,6 +45,13 @@ export interface DaemonCommandDeps {
45
45
  userHome: (user: string) => string;
46
46
  /** The pid is a live `hq daemon run`. */
47
47
  daemonAlive: (pid: number) => boolean;
48
+ /** Blocking sleep for install's wait for the Windows task to start (tests inject). */
49
+ sleep?: (ms: number) => void;
50
+ /**
51
+ * Windows: this user has an active signed-in session, so the logon task
52
+ * can start now. Null when the sessions cannot be listed.
53
+ */
54
+ interactiveSession?: () => boolean | null | "unavailable";
48
55
  /** Test seam for the combined hq-flags and person-setting gate. */
49
56
  runtimeAllowed?: () => boolean | Promise<boolean>;
50
57
  /** Test seam for the company sync-mode commands. */
@@ -24,9 +24,9 @@ import { DEFAULT_SERVICES, SERVICE_DESCRIPTIONS, enabledServices, hqCloudUnits }
24
24
  import { applyServiceToggle, effectiveSyncOwner, readDaemonConfig, writeDaemonConfig } from "../lib/daemon/config.js";
25
25
  import { daemonUnitInstalled } from "../lib/daemon/handoff.js";
26
26
  import { adoptLegacyUnits, readAdoptedUnits, restoreLegacyUnits } from "../lib/daemon/migrate.js";
27
- import { ROOT_REFUSAL, SYSTEM_UNIT_DIR, daemonRootRefusal, installDaemonUnit, installScopeFor, uninstallDaemonUnit, } from "../lib/daemon/install.js";
27
+ import { DAEMON_WINDOWS_TASK, ROOT_REFUSAL, SYSTEM_UNIT_DIR, WINDOWS_START_WAIT_MS, daemonRootRefusal, installDaemonUnit, installScopeFor, uninstallDaemonUnit, windowsInteractiveSession, } from "../lib/daemon/install.js";
28
28
  import { describeLegacyUnit, findLegacyUnits } from "../lib/daemon/legacy.js";
29
- import { isDaemonProcess, runningDaemonPid } from "../lib/daemon/lock.js";
29
+ import { isDaemonProcess, liveDaemonLock, runningDaemonPid } from "../lib/daemon/lock.js";
30
30
  import { bundledHqCloud, managedHqCloud, managedHqCloudRoot, readHqCloudCopy, readHqCloudUpdateState } from "../lib/daemon/hq-cloud.js";
31
31
  import { MACHINE_TYPE_MARKER_PATH, detectMachineType, homeFromPasswd, } from "../lib/daemon/machine-type.js";
32
32
  import { daemonPaths, ensurePrivateDir } from "../lib/daemon/paths.js";
@@ -137,6 +137,8 @@ async function loadDefaults(over) {
137
137
  resolveHqRoot: over.resolveHqRoot ?? ((flag) => resolveDaemonHqRoot(flag, { env, cwd: process.cwd(), home })),
138
138
  userHome: over.userHome ?? ((user) => loginHome(user, run)),
139
139
  daemonAlive: over.daemonAlive ?? ((pid) => isDaemonProcess(pid)),
140
+ sleep: over.sleep,
141
+ interactiveSession: over.interactiveSession,
140
142
  windowsUser: over.windowsUser ?? (platform === "win32" ? mesh.resolveWindowsUser(env) : ""),
141
143
  windowsHeadlessLauncher: over.windowsHeadlessLauncher ?? (platform === "win32" ? mesh.resolveWindowsHeadlessLauncher(env) : undefined),
142
144
  systemUnitDir: over.systemUnitDir ?? SYSTEM_UNIT_DIR,
@@ -298,12 +300,42 @@ async function runInstall(d, opts) {
298
300
  d.writeFile(MACHINE_TYPE_MARKER_PATH, `type=${detected.type}\nuser=${runAsUser}\n`);
299
301
  }
300
302
  const runAsHome = runAsUser ? d.userHome(runAsUser) : undefined;
301
- const result = installDaemonUnit(hostFor(d, paths, scope === "user"), { scope, runAsUser, runAsHome }, { run: d.run });
303
+ const result = installDaemonUnit(hostFor(d, paths, scope === "user"), { scope, runAsUser, runAsHome }, {
304
+ run: d.run,
305
+ sleep: d.sleep,
306
+ runningDaemon: () => liveDaemonLock(paths.lockPath, d.daemonAlive),
307
+ });
302
308
  handOver(d, { paths, detected, runAsUser });
309
+ if (result.warning)
310
+ d.err(result.warning);
303
311
  if (result.kind === "manual") {
304
312
  d.out(`This platform has no supported service manager. Start the daemon yourself with:\n ${result.manualCommand}`);
305
313
  return;
306
314
  }
315
+ if (result.startPending) {
316
+ const signedIn = (d.interactiveSession ?? (() => windowsInteractiveSession(d.run, d.windowsUser ?? "")))();
317
+ // A missing daemon is not a failure when the user is signed out, or when
318
+ // Constrained Language Mode refuses the session check; the latter says the
319
+ // check did not run. A check that failed for any other reason (null) is
320
+ // treated like a known sign-in and exits non-zero.
321
+ if (signedIn === false || signedIn === "unavailable") {
322
+ d.out(`Installed: ${result.unitPath}`);
323
+ if (signedIn === "unavailable") {
324
+ d.out("HQ could not check whether you are signed in to Windows, so it could not confirm the daemon started.");
325
+ }
326
+ d.out("The daemon starts at your next sign-in to Windows. It is not running yet.");
327
+ d.out("Check it with: hq daemon status");
328
+ return;
329
+ }
330
+ const seconds = WINDOWS_START_WAIT_MS / 1000;
331
+ throw new DaemonCommandError([
332
+ signedIn
333
+ ? `Installed ${result.unitPath}, but the daemon did not start within ${seconds} seconds although you are signed in to Windows.`
334
+ : `Installed ${result.unitPath}, but the daemon did not start within ${seconds} seconds, and HQ could not check whether you are signed in to Windows.`,
335
+ "Check it with: hq daemon status",
336
+ `The task's last result: powershell -NoProfile -Command "(Get-ScheduledTaskInfo -TaskName '${DAEMON_WINDOWS_TASK}').LastTaskResult"`,
337
+ ].join("\n"));
338
+ }
307
339
  if (!result.running) {
308
340
  throw new DaemonCommandError(`Installed ${result.unitPath}, but it did not start: ${result.error ?? "unknown error"}`);
309
341
  }
@@ -354,6 +386,8 @@ async function runUninstall(d, opts) {
354
386
  }
355
387
  const result = uninstallDaemonUnit(hostFor(d, paths, false), { scope: installScopeFor(detected.type) }, { run: d.run });
356
388
  d.out(result.removed ? `Removed ${result.unitPath}` : "hq daemon was not installed.");
389
+ if (result.warning)
390
+ d.err(result.warning);
357
391
  if (opts.restoreLegacy) {
358
392
  const restored = restoreLegacyUnits(migrate);
359
393
  for (const u of restored.restored)
@@ -4,6 +4,7 @@ import { ensureCognitoToken } from "../utils/cognito-session.js";
4
4
  import { resolveDestinationCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
5
5
  import { isMachineIdentity } from "@indigoai-us/hq-cloud";
6
6
  import { peekIdToken } from "../utils/id-token.js";
7
+ import { redactAgentInboxItem as redactInboxCredentials } from "../lib/agent-kit/inbox-redact.js";
7
8
  import { EMAIL_PATTERN, RECIPIENT_UID_PATTERN } from "../lib/notify-recipient.js";
8
9
  import { dmForwardCliEnabled } from "./dm-forward-flag.js";
9
10
  /**
@@ -1281,9 +1282,15 @@ function formatAgentInboxItem(item) {
1281
1282
  const text = str("text") || str("body") || str("subject");
1282
1283
  return `${chalk.bold(from)} ${chalk.dim(`[${channel}] ${id}${when ? ` ${when}` : ""}`)}\n ${firstLine(text)}`;
1283
1284
  }
1284
- /** Remove delegated execution context before agent inbox items can be emitted. */
1285
+ /**
1286
+ * Stdout for `hq dm` drops the whole actingContext. Credential removal itself
1287
+ * is redactInboxCredentials, shared with the mirror, so a later edit cannot
1288
+ * leave a credential key on one path only. The extra omit stays here because
1289
+ * terminal output must not grow the delegation block back.
1290
+ */
1285
1291
  function redactAgentInboxItem(item) {
1286
- const { actingContext: _actingContext, ...safe } = item;
1292
+ const redacted = redactInboxCredentials(item);
1293
+ const { actingContext: _actingContext, ...safe } = redacted;
1287
1294
  return safe;
1288
1295
  }
1289
1296
  async function runAgentInbox(token, agentUid, opts) {
@@ -25,6 +25,7 @@ import { Command } from "commander";
25
25
  import { type DoctorRegistry } from "../lib/doctor/registry.js";
26
26
  import type { FamilyRun } from "../lib/doctor/types.js";
27
27
  import { type DoctorPlatform } from "../lib/doctor/report.js";
28
+ import { type TempHomeSweepResult } from "../lib/doctor/temp-home-sweeper.js";
28
29
  /**
29
30
  * Marker directories that identify an HQ tree root. A directory is an HQ root
30
31
  * only when it contains ALL of these. `.claude` + `core` are present in both
@@ -126,6 +127,15 @@ export interface RunDoctorResult {
126
127
  export declare function runDoctor(options?: RunDoctorOptions): Promise<RunDoctorResult>;
127
128
  export declare function createClientHealthReasonCodeCompanyResolver(hqRoot: string): (companySlug: string | null) => Promise<boolean>;
128
129
  export declare function createClientHealthCheckStatusCompanyResolver(hqRoot: string): (companySlug: string | null) => Promise<boolean>;
130
+ /** Test seams for {@link registerDoctorCommand}. */
131
+ export interface DoctorCommandDeps {
132
+ /** Runs the temp-home sweep for `--sweep-temp-homes`. */
133
+ sweepTempHomes?: (options: {
134
+ dryRun: boolean;
135
+ }) => TempHomeSweepResult;
136
+ }
137
+ /** Text report for `hq doctor --sweep-temp-homes`. */
138
+ export declare function renderTempHomeSweepText(result: TempHomeSweepResult): string;
129
139
  /** Register the top-level `hq doctor` command so it appears in `hq --help`. */
130
- export declare function registerDoctorCommand(program: Command): void;
140
+ export declare function registerDoctorCommand(program: Command, deps?: DoctorCommandDeps): void;
131
141
  //# sourceMappingURL=doctor.d.ts.map
@@ -37,6 +37,7 @@ import { clientHealthCompanyScopeFromCheckId } from "../lib/doctor/checks/sync-h
37
37
  import { readCompaniesManifestMap } from "../lib/work-context/repo-remote.js";
38
38
  import * as readline from "node:readline";
39
39
  import { runSideEffectTests } from "../lib/doctor/deep/sandbox.js";
40
+ import { sweepTempHomes, } from "../lib/doctor/temp-home-sweeper.js";
40
41
  /**
41
42
  * Marker directories that identify an HQ tree root. A directory is an HQ root
42
43
  * only when it contains ALL of these. `.claude` + `core` are present in both
@@ -315,8 +316,33 @@ export function createClientHealthCheckStatusCompanyResolver(hqRoot) {
315
316
  });
316
317
  };
317
318
  }
319
+ /** Text report for `hq doctor --sweep-temp-homes`. */
320
+ export function renderTempHomeSweepText(result) {
321
+ const lines = [];
322
+ const verb = result.dryRun ? "Would remove" : "Removed";
323
+ for (const entry of result.entries) {
324
+ if (entry.action === "removed" || entry.action === "would-remove") {
325
+ lines.push(` ${verb.toLowerCase()} ${entry.path}`);
326
+ }
327
+ else if (entry.action === "failed") {
328
+ lines.push(` failed ${entry.path}: ${entry.error ?? "unknown error"}`);
329
+ }
330
+ else if (entry.reason === "in-use") {
331
+ lines.push(` kept ${entry.path} (in use)`);
332
+ }
333
+ else if (entry.reason === "in-use-unknown") {
334
+ lines.push(` kept ${entry.path} (could not check whether it is in use: ${entry.error ?? "unknown error"})`);
335
+ }
336
+ }
337
+ const count = result.entries.filter((entry) => entry.action === "removed" || entry.action === "would-remove").length;
338
+ const failed = result.entries.filter((entry) => entry.action === "failed").length;
339
+ lines.push(`${verb} ${count} idle temp home${count === 1 ? "" : "s"}; ` +
340
+ `${result.dryRun ? "would free" : "freed"} ${result.gbFreed.toFixed(2)} GB.` +
341
+ (failed > 0 ? ` ${failed} could not be removed.` : ""));
342
+ return `${lines.join("\n")}\n`;
343
+ }
318
344
  /** Register the top-level `hq doctor` command so it appears in `hq --help`. */
319
- export function registerDoctorCommand(program) {
345
+ export function registerDoctorCommand(program, deps = {}) {
320
346
  program
321
347
  .command("doctor")
322
348
  .description("Verify HQ hook wiring, runtimes, and connection health (read-only; integrations uses one control-plane inventory read).")
@@ -330,9 +356,38 @@ export function registerDoctorCommand(program) {
330
356
  .option("--only <families>", "Run only these comma-separated check families (e.g. hooks). Unknown names are rejected; --json then records the scope.")
331
357
  .option("--fix", "Apply the allowlisted safe repairs (backs up first; read-only without this flag).")
332
358
  .addOption(new Option("--client-health-report", "Resolve the opt-in hq-flags gate used by the client-health report hook.").hideHelp())
359
+ .option("--sweep-temp-homes", "Delete idle temp homes (90+ minutes untouched, not in use): tmp.* folders holding .rustup or .cargo, and hq-temp-home.* folders left by the wrapper. Reports GB freed.")
360
+ .option("--dry-run", "With --sweep-temp-homes, list what would be removed and delete nothing.")
333
361
  .option("--yes", "Skip the interactive --fix confirmation (non-interactive use).")
334
362
  .option("--force", "Let --fix run despite uncommitted changes under .claude/, .codex/, or .grok/.")
335
363
  .action(async (opts, command) => {
364
+ // `--sweep-temp-homes` is a second write path with its own fixed
365
+ // scope (idle, unused temp homes in the temp dirs). Like `--fix`,
366
+ // it refuses a family scope it would otherwise ignore, and it does not
367
+ // combine with `--fix`.
368
+ if (opts.sweepTempHomes === true) {
369
+ if (parseOnly(opts.only) !== undefined || opts.fix === true) {
370
+ process.stderr.write(`hq doctor: --sweep-temp-homes cannot be combined with --only or --fix.\n` +
371
+ ` It removes only idle temp homes in the temp folders; run it on its own.\n`);
372
+ process.exitCode = 1;
373
+ return;
374
+ }
375
+ const sweep = deps.sweepTempHomes ?? ((options) => sweepTempHomes(options));
376
+ const result = sweep({ dryRun: opts.dryRun === true });
377
+ if (opts.json === true) {
378
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
379
+ }
380
+ else {
381
+ process.stdout.write(renderTempHomeSweepText(result));
382
+ }
383
+ process.exitCode = result.entries.some((entry) => entry.action === "failed") ? 1 : 0;
384
+ return;
385
+ }
386
+ if (opts.dryRun === true) {
387
+ process.stderr.write(`hq doctor: --dry-run only applies to --sweep-temp-homes.\n`);
388
+ process.exitCode = 1;
389
+ return;
390
+ }
336
391
  // `--fix` repairs the whole tree from its own allowlist and has no
337
392
  // notion of a check family, so a scope passed alongside it could only
338
393
  // be ignored. Ignoring it silently is the dangerous reading: an