@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
@@ -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,22 +38,152 @@ 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
  #
42
49
  `.replace(/^# ?/gm, "");
43
- function defaultHq(args, cwd, env) {
44
- const spawnPlan = planHqSpawn(args, env, cwd);
50
+ /**
51
+ * Per-call ceiling for each child `hq` call. SessionStart runs this helper, so
52
+ * a stalled vault request must not outlive the session that started it.
53
+ */
54
+ export const DEFAULT_HQ_CALL_TIMEOUT_MS = 30_000;
55
+ function hqCallTimeoutMs(env) {
56
+ const parsed = Number(env.HQ_VAULT_ACCESS_TIMEOUT_MS);
57
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_HQ_CALL_TIMEOUT_MS;
58
+ }
59
+ /**
60
+ * Supervisor for one child call when the whole process tree must die on
61
+ * timeout. On Windows `hq` is a cmd.exe shim around node, and spawnSync's
62
+ * timeout ends only cmd.exe: the node child keeps running and keeps the
63
+ * output pipes open. The supervisor runs the call asynchronously, kills the
64
+ * tree on timeout (`taskkill /T /F` on Windows, the process group elsewhere),
65
+ * and writes the result as JSON. It exits without waiting for pipes still
66
+ * held by a grandchild, so the parent's spawnSync returns.
67
+ */
68
+ const TREE_KILL_SUPERVISOR = String.raw `
69
+ const { spawn, spawnSync } = require("node:child_process");
70
+ const path = require("node:path");
71
+ const plan = JSON.parse(process.env.HQ_TREE_KILL_PLAN);
72
+ delete process.env.HQ_TREE_KILL_PLAN;
73
+ const win = process.platform === "win32";
74
+ let done = false;
75
+ const finish = (result) => {
76
+ if (done) return;
77
+ done = true;
78
+ process.stdout.write(JSON.stringify(result), () => process.exit(0));
79
+ };
80
+ const out = [];
81
+ const err = [];
82
+ let child;
83
+ try {
84
+ child = spawn(plan.cmd, plan.args, {
85
+ cwd: plan.cwd,
86
+ env: process.env,
87
+ stdio: ["ignore", "pipe", "pipe"],
88
+ shell: plan.shell,
89
+ windowsHide: true,
90
+ windowsVerbatimArguments: plan.verbatim,
91
+ detached: !win,
92
+ });
93
+ } catch (error) {
94
+ finish({ error: String(error && error.message), errorCode: error && error.code });
95
+ }
96
+ if (child) {
97
+ const killTree = () => {
98
+ if (child.pid === undefined) return;
99
+ if (win) {
100
+ const taskkill = process.env.SystemRoot ? path.join(process.env.SystemRoot, "System32", "taskkill.exe") : "taskkill";
101
+ spawnSync(taskkill, ["/T", "/F", "/PID", String(child.pid)], { stdio: "ignore", windowsHide: true, timeout: 10000 });
102
+ } else {
103
+ try { process.kill(-child.pid, "SIGKILL"); } catch {}
104
+ }
105
+ };
106
+ const timer = setTimeout(() => { killTree(); finish({ timedOut: true }); }, plan.timeoutMs);
107
+ child.stdout.on("data", (chunk) => out.push(chunk));
108
+ child.stderr.on("data", (chunk) => err.push(chunk));
109
+ const result = (code) => ({ code, stdout: Buffer.concat(out).toString("utf8"), stderr: Buffer.concat(err).toString("utf8") });
110
+ child.on("error", (error) => { clearTimeout(timer); finish({ error: error.message, errorCode: error.code }); });
111
+ child.on("close", (code) => { clearTimeout(timer); finish(result(code ?? 128)); });
112
+ // A grandchild that outlives the call can hold the pipes open. Report once
113
+ // the direct child has exited and the pipes have had a moment to drain.
114
+ child.on("exit", (code) => { setTimeout(() => { clearTimeout(timer); finish(result(code ?? 128)); }, 500).unref(); });
115
+ }
116
+ `;
117
+ /** Extra time the parent allows the supervisor beyond the call timeout, for taskkill and startup. */
118
+ const SUPERVISOR_GRACE_MS = 15_000;
119
+ /**
120
+ * Run one child `hq` call with a timeout and with browser sign-in disabled.
121
+ * Without `HQ_NO_BROWSER_LOGIN` (read by cognito-session's
122
+ * resolveInteractiveLogin) a signed-out child opens a browser login and
123
+ * waits on it indefinitely. On Windows the call runs under a supervisor so a
124
+ * timeout ends the cmd.exe shim and every process under it.
125
+ */
126
+ /** Windows npm shims run hq under cmd.exe, so only there does a plain kill leave the real hq running. */
127
+ export function usesTreeKillByDefault(platform = process.platform) {
128
+ return platform === "win32";
129
+ }
130
+ export function runHqChild(args, cwd, env, killTree = usesTreeKillByDefault()) {
131
+ const childEnv = { ...env, HQ_NO_BROWSER_LOGIN: "1" };
132
+ const timeoutMs = hqCallTimeoutMs(env);
133
+ const timedOut = () => ({ code: 124, stdout: "", stderr: `hq ${args.join(" ")} timed out after ${timeoutMs}ms`, timedOut: true });
134
+ const failed = (message, code) => ({ code: 127, stdout: "", stderr: message, missing: code === "ENOENT" });
135
+ const spawnPlan = planHqSpawn(args, childEnv, cwd);
136
+ if (killTree) {
137
+ const plan = JSON.stringify({
138
+ cmd: spawnPlan.cmd,
139
+ args: spawnPlan.args,
140
+ cwd,
141
+ shell: spawnPlan.shell,
142
+ verbatim: Boolean(spawnPlan.windowsVerbatimArguments),
143
+ timeoutMs,
144
+ });
145
+ const supervised = spawnSync(process.execPath, ["-e", TREE_KILL_SUPERVISOR], {
146
+ cwd,
147
+ env: { ...childEnv, HQ_TREE_KILL_PLAN: plan },
148
+ encoding: "utf8",
149
+ stdio: ["ignore", "pipe", "pipe"],
150
+ timeout: timeoutMs + SUPERVISOR_GRACE_MS,
151
+ killSignal: "SIGKILL",
152
+ maxBuffer: 64 * 1024 * 1024,
153
+ windowsHide: true,
154
+ });
155
+ if (supervised.error) {
156
+ const code = supervised.error.code;
157
+ return code === "ETIMEDOUT" ? timedOut() : failed(supervised.error.message, code);
158
+ }
159
+ let parsed;
160
+ try {
161
+ parsed = JSON.parse(String(supervised.stdout ?? ""));
162
+ }
163
+ catch {
164
+ return { code: supervised.status ?? 128, stdout: "", stderr: String(supervised.stderr ?? "") };
165
+ }
166
+ if (parsed.timedOut)
167
+ return timedOut();
168
+ if (parsed.error !== undefined)
169
+ return failed(parsed.error, parsed.errorCode);
170
+ return { code: parsed.code ?? 128, stdout: parsed.stdout ?? "", stderr: parsed.stderr ?? "" };
171
+ }
45
172
  const result = spawnSync(spawnPlan.cmd, spawnPlan.args, {
46
173
  cwd,
47
- env,
174
+ env: childEnv,
48
175
  encoding: "utf8",
49
176
  stdio: ["ignore", "pipe", "pipe"],
50
177
  shell: spawnPlan.shell,
178
+ timeout: timeoutMs,
179
+ killSignal: "SIGKILL",
180
+ windowsHide: true,
51
181
  ...(spawnPlan.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
52
182
  });
53
- if (result.error)
54
- return { code: 127, stdout: "", stderr: result.error.message, missing: result.error.code === "ENOENT" };
183
+ if (result.error) {
184
+ const code = result.error.code;
185
+ return code === "ETIMEDOUT" ? timedOut() : failed(result.error.message, code);
186
+ }
55
187
  return { code: result.status ?? 128, stdout: String(result.stdout ?? ""), stderr: String(result.stderr ?? "") };
56
188
  }
57
189
  function executableOnPath(name, value) {
@@ -78,7 +210,7 @@ export function createRefreshVaultAccessDependencies(cwd, env, now) {
78
210
  env,
79
211
  now,
80
212
  hqAvailable: executableOnPath("hq", env.PATH),
81
- runHq: (args, directory) => defaultHq(args, directory, env),
213
+ runHq: (args, directory) => runHqChild(args, directory, env),
82
214
  };
83
215
  }
84
216
  function isHqRoot(root) {
@@ -167,13 +299,18 @@ function roleFromTable(output, email) {
167
299
  }
168
300
  return "unknown";
169
301
  }
302
+ /** `hq whoami` prints this when no person session is cached. */
303
+ const SIGNED_OUT_PATTERN = /^Not logged in\b/m;
170
304
  function resolveEmail(deps, runHq) {
171
305
  const override = deps.env.HQ_VAULT_ACCESS_EMAIL ?? "";
172
306
  if (override)
173
- return { email: override, stderr: "" };
307
+ return { email: override, stderr: "", signedOut: false };
174
308
  const result = runHq(["whoami"], deps.cwd);
175
309
  const email = getEmail(result.stdout);
176
- return { email, stderr: email ? "" : log("could not determine caller email — roles will be 'unknown' (hook will not enforce)") };
310
+ // An API key reaches the vault without a person session, so only a
311
+ // key-less caller whose whoami reports no session is treated as signed out.
312
+ const signedOut = !email && !deps.env.HQ_API_KEY?.trim() && SIGNED_OUT_PATTERN.test(cleanAnsi(result.stdout));
313
+ return { email, signedOut, stderr: email ? "" : log("could not determine caller email — roles will be 'unknown' (hook will not enforce)") };
177
314
  }
178
315
  export function runRefreshVaultAccess(args, deps) {
179
316
  const parsed = parseOptions(args);
@@ -186,11 +323,19 @@ export function runRefreshVaultAccess(args, deps) {
186
323
  return { code: 1, stdout: "", stderr: log("could not resolve the HQ root (pass --root <hqRoot>)") };
187
324
  if (!deps.hqAvailable)
188
325
  return { code: 0, stdout: "", stderr: log("hq CLI not found — nothing refreshed (the write-access hook stays fail-open without a manifest)") };
189
- const runHq = deps.runHq ?? ((hqArgs, cwd) => defaultHq(hqArgs, cwd, deps.env));
326
+ const runHq = deps.runHq ?? ((hqArgs, cwd) => runHqChild(hqArgs, cwd, deps.env));
190
327
  const identity = resolveEmail(deps, runHq);
328
+ if (identity.signedOut) {
329
+ return { code: 0, stdout: "", stderr: log("not signed in to HQ — nothing refreshed (run `hq login`; the write-access hook stays fail-open without a manifest)") };
330
+ }
191
331
  let stderr = identity.stderr;
192
- const explicit = parsed.slugs;
332
+ const explicit = [...new Set(parsed.slugs)];
193
333
  const allGrants = new Map();
334
+ // Companies whose entry this run replaces. A scoped run (--company) owns only
335
+ // the companies it queried successfully; every other company already in the
336
+ // manifest is carried over untouched. The unscoped roll-up owns the whole
337
+ // manifest, so a company that lost all its grants drops out of it.
338
+ const refreshed = new Set();
194
339
  if (explicit.length > 0) {
195
340
  for (const slug of explicit) {
196
341
  const result = runHq(["files", "shared-with-me", "--company", slug], deps.cwd);
@@ -198,45 +343,228 @@ export function runRefreshVaultAccess(args, deps) {
198
343
  stderr += log(`skipping ${slug} — shared-with-me query failed`);
199
344
  continue;
200
345
  }
201
- const rows = parseGrantTable(result.stdout);
202
- for (const [company, grants] of rows)
203
- allGrants.set(company, [...(allGrants.get(company) ?? []), ...grants]);
346
+ refreshed.add(slug);
347
+ const grants = parseGrantTable(result.stdout).get(slug) ?? [];
348
+ allGrants.set(slug, [...(allGrants.get(slug) ?? []), ...grants]);
204
349
  }
205
350
  }
206
351
  else {
207
352
  const result = runHq(["files", "shared-with-me"], deps.cwd);
208
353
  if (result.code !== 0)
209
354
  return { code: 0, stdout: "", stderr: `${stderr}${log("shared-with-me roll-up failed — nothing refreshed")}` };
210
- for (const [company, grants] of parseGrantTable(result.stdout))
355
+ for (const [company, grants] of parseGrantTable(result.stdout)) {
211
356
  allGrants.set(company, grants);
357
+ refreshed.add(company);
358
+ }
212
359
  }
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)
360
+ if (explicit.length > 0 && refreshed.size === 0)
361
+ return { code: 0, stdout: "", stderr: `${stderr}${log("no requested company could be queried — nothing refreshed")}` };
362
+ if (explicit.length === 0 && refreshed.size === 0)
218
363
  stderr += log("no explicit grants found — writing an empty manifest (hook stays fail-open everywhere)");
219
- const companyData = {};
220
- for (const slug of sortedSlugs) {
364
+ const fresh = new Map();
365
+ for (const slug of [...refreshed].sort()) {
221
366
  let role = "unknown";
222
367
  if (identity.email) {
223
368
  const result = runHq(["members", "--company", slug, "list"], deps.cwd);
224
369
  role = result.code === 0 ? roleFromTable(result.stdout, identity.email) : "unknown";
225
370
  }
226
371
  const grants = (allGrants.get(slug) ?? []).map((grant) => ({ path: grant.path, permission: grant.permission }));
227
- companyData[slug] = { role, grants };
372
+ fresh.set(slug, { role, grants });
228
373
  }
229
- const manifest = { version: 1, generatedAt: deps.now().toISOString().replace(/\.\d{3}Z$/, "Z"), companies: companyData };
230
374
  const directory = path.join(root, ".hq");
231
375
  fs.mkdirSync(directory, { recursive: true });
232
376
  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"})`);
377
+ // Read the current manifest only once the lock is held, after the slow hq
378
+ // queries, so a refresh for another company that finished meanwhile is kept.
379
+ const written = withManifestLock(target, deps.lockWaitMs ?? LOCK_WAIT_MS, (stillOwned) => {
380
+ // Company slugs come from --company and from the manifest, so they are
381
+ // kept in a Map and a null-prototype object; "__proto__" stays a plain key.
382
+ const companyData = new Map();
383
+ if (explicit.length > 0) {
384
+ const existing = readCompanies(target);
385
+ stderr += existing.warning;
386
+ for (const [slug, entry] of Object.entries(existing.companies))
387
+ if (!refreshed.has(slug))
388
+ companyData.set(slug, entry);
389
+ }
390
+ for (const [slug, entry] of fresh)
391
+ companyData.set(slug, entry);
392
+ const sorted = Object.create(null);
393
+ for (const slug of [...companyData.keys()].sort())
394
+ sorted[slug] = companyData.get(slug);
395
+ const manifest = { version: 1, generatedAt: deps.now().toISOString().replace(/\.\d{3}Z$/, "Z"), companies: sorted };
396
+ const temp = `${target}.${process.pid}.${randomBytes(8).toString("hex")}`;
397
+ fs.writeFileSync(temp, serializeJqJson(manifest, "pretty"), { encoding: "utf8", mode: 0o600, flag: "wx" });
398
+ try {
399
+ fs.chmodSync(temp, 0o600);
400
+ // A refresh that held the lock past the stale threshold may have been
401
+ // taken over; it must not overwrite the new holder's merge.
402
+ if (!stillOwned()) {
403
+ fs.rmSync(temp, { force: true });
404
+ return undefined;
405
+ }
406
+ fs.renameSync(temp, target);
407
+ }
408
+ catch (err) {
409
+ fs.rmSync(temp, { force: true });
410
+ throw err;
411
+ }
412
+ return companyData.size;
413
+ });
414
+ if (written === undefined)
415
+ return { code: 0, stdout: "", stderr: `${stderr}${log("another refresh is still writing .hq/vault-access.json — nothing refreshed")}` };
416
+ const scope = explicit.length > 0 ? `, refreshed: ${[...refreshed].sort().join(", ")}` : "";
417
+ stderr += log(`wrote .hq/vault-access.json (${written} companies${scope}, identity: ${identity.email || "unknown"})`);
238
418
  return { code: 0, stdout: "", stderr };
239
419
  }
420
+ /** Company entries already in the manifest; empty, with a warning, when it is missing or unreadable. */
421
+ function readCompanies(target) {
422
+ let text;
423
+ try {
424
+ text = fs.readFileSync(target, "utf8");
425
+ }
426
+ catch (err) {
427
+ const code = err.code;
428
+ return { companies: {}, warning: code === "ENOENT" ? "" : log(`could not read the existing manifest (${code ?? "error"}) — other companies' entries are not carried over`) };
429
+ }
430
+ try {
431
+ const companies = JSON.parse(text)?.companies;
432
+ if (companies && typeof companies === "object" && !Array.isArray(companies))
433
+ return { companies: companies, warning: "" };
434
+ }
435
+ catch { /* reported below */ }
436
+ return { companies: {}, warning: log("the existing manifest is not valid — other companies' entries are not carried over") };
437
+ }
438
+ /** A lock older than this belongs to a refresh that died holding it. */
439
+ const LOCK_STALE_MS = 30_000;
440
+ const LOCK_WAIT_MS = 5_000;
441
+ function sleepSync(ms) {
442
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
443
+ }
444
+ function readLockToken(lock) {
445
+ try {
446
+ return fs.readFileSync(lock, "utf8");
447
+ }
448
+ catch {
449
+ return undefined;
450
+ }
451
+ }
452
+ /** True when the token names a process on this host that no longer exists. */
453
+ function holderIsGone(token) {
454
+ const match = /^(\d+):(.*):[0-9a-f]+$/.exec(token);
455
+ if (!match || match[2] !== os.hostname())
456
+ return false;
457
+ const pid = Number(match[1]);
458
+ if (pid === process.pid)
459
+ return false;
460
+ try {
461
+ process.kill(pid, 0);
462
+ return false;
463
+ }
464
+ catch (err) {
465
+ return err.code === "ESRCH";
466
+ }
467
+ }
468
+ /**
469
+ * Remove the lock only if it still carries `expected`. The lock is first
470
+ * renamed to a name private to this caller, which only one process can do for
471
+ * a given lock file. If the moved file is not the expected lock (another
472
+ * process replaced it in the meantime), it is linked back under the lock name
473
+ * unchanged.
474
+ */
475
+ function removeLockIf(lock, expected, id) {
476
+ const moved = `${lock}.${id}.moved`;
477
+ try {
478
+ fs.renameSync(lock, moved);
479
+ }
480
+ catch {
481
+ return false;
482
+ }
483
+ const ours = readLockToken(moved) === expected;
484
+ if (!ours) {
485
+ try {
486
+ fs.linkSync(moved, lock);
487
+ }
488
+ catch { /* a newer lock already exists; leave it */ }
489
+ }
490
+ fs.rmSync(moved, { force: true });
491
+ return ours;
492
+ }
493
+ /**
494
+ * Take the manifest lock and return its owner token, or undefined when the
495
+ * lock stays busy past `waitMs`. The lock file is created by hard-linking a
496
+ * file that already holds the token, so it never exists without an owner. A
497
+ * stale lock (older than LOCK_STALE_MS, or whose holder on this host has
498
+ * exited) is taken over through removeLockIf, so two waiters that both judge
499
+ * it stale cannot remove each other's new lock.
500
+ */
501
+ export function acquireManifestLock(lock, waitMs, hooks = {}) {
502
+ const nonce = randomBytes(8).toString("hex");
503
+ const id = `${process.pid}.${nonce}`;
504
+ const token = `${process.pid}:${os.hostname()}:${nonce}`;
505
+ const pending = `${lock}.${id}.new`;
506
+ const deadline = Date.now() + Math.max(0, waitMs);
507
+ fs.writeFileSync(pending, token, { flag: "wx", mode: 0o600 });
508
+ try {
509
+ for (;;) {
510
+ try {
511
+ fs.linkSync(pending, lock);
512
+ return token;
513
+ }
514
+ catch (err) {
515
+ if (err.code !== "EEXIST")
516
+ throw err;
517
+ }
518
+ const held = readLockToken(lock);
519
+ let age;
520
+ // lstat, so a lock name that is a dangling link still counts as held.
521
+ try {
522
+ age = Date.now() - fs.lstatSync(lock).mtimeMs;
523
+ }
524
+ catch {
525
+ continue;
526
+ }
527
+ if (held !== undefined && (age > LOCK_STALE_MS || holderIsGone(held))) {
528
+ hooks.afterStaleCheck?.();
529
+ if (removeLockIf(lock, held, id))
530
+ continue;
531
+ }
532
+ // Every path that did not just free the lock waits and honours the
533
+ // deadline, so a lock that cannot be read or taken over never spins.
534
+ if (Date.now() >= deadline)
535
+ return undefined;
536
+ sleepSync(25);
537
+ }
538
+ }
539
+ finally {
540
+ fs.rmSync(pending, { force: true });
541
+ }
542
+ }
543
+ /** Release the lock if `token` still owns it. Returns false when another process has taken it over. */
544
+ export function releaseManifestLock(lock, token) {
545
+ if (readLockToken(lock) !== token)
546
+ return false;
547
+ return removeLockIf(lock, token, `${process.pid}.${randomBytes(8).toString("hex")}`);
548
+ }
549
+ /**
550
+ * Serialize the read-merge-write of the manifest across processes. Without it
551
+ * two scoped refreshes for different companies can each read the old file and
552
+ * the second rename drops the first one's company. `fn` gets a check that the
553
+ * lock is still held, to call just before it commits. Returns undefined when
554
+ * the lock stays busy past `waitMs`.
555
+ */
556
+ function withManifestLock(target, waitMs, fn) {
557
+ const lock = `${target}.lock`;
558
+ const token = acquireManifestLock(lock, waitMs);
559
+ if (token === undefined)
560
+ return undefined;
561
+ try {
562
+ return fn(() => readLockToken(lock) === token);
563
+ }
564
+ finally {
565
+ releaseManifestLock(lock, token);
566
+ }
567
+ }
240
568
  export function registerCoreRefreshVaultAccessNative(core) {
241
569
  registerNativeUtility(core, {
242
570
  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,14 @@ 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
+ .option('--for-registration', 'Print a path only when the offline launcher flag is enabled')
447
+ .action((options) => runHookEnginePath([
448
+ ...(options.repair ? ['--repair'] : []), ...(options.forRegistration ? ['--for-registration'] : []),
449
+ ]));
450
+ describeCoreCommand(hookEnginePath, { execution: 'native', root: 'cwd' });
442
451
  registerCoreOntologyCommand(core);
443
452
  registerCoreCheckpointGateSettingCommand(core);
444
453
  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. */