@indigoai-us/hq-cli 5.345.47 → 5.345.49

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 (49) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +2 -1
  3. package/assets/scaffold/core/scripts/hq-session.sh +113 -7
  4. package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +101 -0
  5. package/dist/command-catalog.generated.d.ts +12 -0
  6. package/dist/command-catalog.generated.js +15 -0
  7. package/dist/commands/anywhere-runtime-flag.js +5 -3
  8. package/dist/commands/core-hq-session-native.d.ts +2 -0
  9. package/dist/commands/core-hq-session-native.js +242 -52
  10. package/dist/commands/core-resolve-company-native.d.ts +1 -0
  11. package/dist/commands/core-resolve-company-native.js +41 -11
  12. package/dist/commands/feedback.d.ts +10 -0
  13. package/dist/commands/feedback.js +45 -0
  14. package/dist/commands/lanes-message.js +2 -2
  15. package/dist/commands/lanes.js +2 -2
  16. package/dist/commands/mcp-serve.js +17 -4
  17. package/dist/commands/secrets.d.ts +5 -0
  18. package/dist/commands/secrets.js +181 -2
  19. package/dist/commands/workers.d.ts +4 -1
  20. package/dist/commands/workers.js +57 -15
  21. package/dist/lib/anywhere-person-setting.d.ts +1 -0
  22. package/dist/lib/anywhere-person-setting.js +12 -0
  23. package/dist/lib/anywhere-runtime-fast-flag.js +5 -3
  24. package/dist/lib/anywhere-runtime-flag-key.d.ts +3 -1
  25. package/dist/lib/anywhere-runtime-flag-key.js +6 -1
  26. package/dist/lib/cli-flag-keys.d.ts +1 -0
  27. package/dist/lib/cli-flag-keys.js +1 -0
  28. package/dist/lib/core-utils/session-id.d.ts +6 -0
  29. package/dist/lib/core-utils/session-id.js +35 -0
  30. package/dist/lib/daemon/catalog.js +1 -1
  31. package/dist/lib/daemon/flag.d.ts +1 -1
  32. package/dist/lib/daemon/flag.js +20 -9
  33. package/dist/lib/daemon/lanes-reconcile-guard.d.ts +6 -5
  34. package/dist/lib/daemon/lanes-reconcile-guard.js +26 -7
  35. package/dist/lib/daemon/run.d.ts +6 -0
  36. package/dist/lib/daemon/run.js +36 -9
  37. package/dist/lib/daemon/server.d.ts +1 -1
  38. package/dist/lib/daemon/supervisor.d.ts +7 -1
  39. package/dist/lib/daemon/supervisor.js +50 -4
  40. package/dist/lib/lanes/reconcile.js +6 -4
  41. package/dist/lib/lanes/registry-flag.d.ts +11 -1
  42. package/dist/lib/lanes/registry-flag.js +68 -29
  43. package/dist/lib/lanes/watcher.js +63 -2
  44. package/dist/utils/resident-package-preload.js +8 -1
  45. package/dist/utils/self-update.js +6 -5
  46. package/dist/utils/version-gate.d.ts +11 -4
  47. package/dist/utils/version-gate.js +132 -41
  48. package/docs/anywhere-runtime.md +5 -3
  49. package/package.json +1 -1
@@ -2,10 +2,13 @@
2
2
  import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
+ import { createRequire } from "node:module";
5
6
  import { spawn, spawnSync } from "node:child_process";
6
7
  import { awkCannotOpenFileDiagnostic } from "../lib/core-utils/awk-diagnostics.js";
7
8
  import { shellDisplayPath } from "../lib/core-utils/shell-display-path.js";
9
+ import { validSessionId, resolveSessionId as resolveSharedSessionId } from "../lib/core-utils/session-id.js";
8
10
  export const HQ_SESSION_ASSET = "core/scripts/hq-session.sh";
11
+ const requireSessionDependency = createRequire(import.meta.url);
9
12
  export const HQ_SESSION_HELP = [
10
13
  "hq-core: public",
11
14
  "hq-session.sh — read/write the current session's metadata.",
@@ -15,6 +18,9 @@ export const HQ_SESSION_HELP = [
15
18
  " core/scripts/hq-session.sh path # print path to current meta.yaml",
16
19
  " core/scripts/hq-session.sh get <key> # read a key from meta.yaml",
17
20
  " core/scripts/hq-session.sh set <key> <value> # set/replace a top-level key",
21
+ " add company <slug> # add a company to the session lock set",
22
+ " remove company <slug> # remove a company from the session lock set",
23
+ " get company_slugs # print locked companies, primary first",
18
24
  "",
19
25
  "Reserved value:",
20
26
  " set company_slug personal # bind the session to no-company (personal) work",
@@ -105,44 +111,69 @@ const defaultDependencies = () => ({
105
111
  ...(result.error ? { error: result.error } : {}),
106
112
  };
107
113
  },
114
+ multiCompanyLockEnabled: false,
108
115
  });
109
116
  function isExpectedFsMiss(error) {
110
117
  const code = error?.code;
111
118
  return code === "ENOENT" || code === "ENOTDIR" || code === "EACCES" || code === "ELOOP";
112
119
  }
113
- function asciiWhitespace(value) {
114
- return value.replace(/[\t\n\v\f\r ]/g, "");
115
- }
116
- function validSessionId(value) {
117
- return value.length > 0 && value !== "." && value !== ".." && /^[A-Za-z0-9._-]+$/.test(value);
118
- }
119
- function sessionIdFromEnv(env) {
120
- for (const name of [
121
- "HQ_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID",
122
- "CODEX_SESSION_ID", "CODEX_THREAD_ID", "GROK_SESSION_ID",
123
- ]) {
124
- const value = asciiWhitespace(env[name] ?? "");
125
- if (validSessionId(value))
126
- return value;
127
- }
128
- return "";
120
+ export { validSessionId } from "../lib/core-utils/session-id.js";
121
+ function resolveSessionId(root, deps) {
122
+ return resolveSharedSessionId(root, deps.env, deps.readFile);
129
123
  }
130
- function sessionIdFromCurrentFile(root, deps) {
131
- const filePath = path.join(root, "workspace", "sessions", ".current");
132
- if (!deps.isFile(filePath))
133
- return "";
124
+ function isRegisteredCompany(root, slug, deps) {
125
+ if (!slug || slug.startsWith("_") || slug === "companies" || slug === "unaffiliated_repos")
126
+ return false;
134
127
  try {
135
- const value = asciiWhitespace(deps.readFile(filePath, "utf8"));
136
- return validSessionId(value) ? value : "";
128
+ const yaml = requireSessionDependency("js-yaml");
129
+ const value = yaml.load(deps.readFile(path.join(root, "companies", "manifest.yaml"), "utf8"));
130
+ if (!value || typeof value !== "object" || Array.isArray(value))
131
+ return false;
132
+ const companies = value.companies;
133
+ return Boolean(companies && typeof companies === "object" && !Array.isArray(companies) && Object.hasOwn(companies, slug));
137
134
  }
138
- catch (error) {
139
- if (isExpectedFsMiss(error))
140
- return "";
141
- throw error;
135
+ catch {
136
+ return false;
142
137
  }
143
138
  }
144
- function resolveSessionId(root, deps) {
145
- return sessionIdFromEnv(deps.env) || sessionIdFromCurrentFile(root, deps);
139
+ function withCompanyLock(root, sessionId, action) {
140
+ const lock = path.join(root, "workspace", "sessions", sessionId, ".company-lock-set.lock");
141
+ const deadline = Date.now() + 30_000;
142
+ while (true) {
143
+ try {
144
+ fs.mkdirSync(lock);
145
+ fs.writeFileSync(path.join(lock, "pid"), `${process.pid}\n`, "utf8");
146
+ break;
147
+ }
148
+ catch (error) {
149
+ if (error.code !== "EEXIST")
150
+ throw error;
151
+ try {
152
+ const owner = Number.parseInt(fs.readFileSync(path.join(lock, "pid"), "utf8").trim(), 10);
153
+ if (Number.isInteger(owner) && owner > 0) {
154
+ try {
155
+ process.kill(owner, 0);
156
+ }
157
+ catch (probeError) {
158
+ if (probeError.code === "ESRCH") {
159
+ fs.rmSync(lock, { recursive: true, force: true });
160
+ continue;
161
+ }
162
+ }
163
+ }
164
+ }
165
+ catch { /* The shell writer may be between mkdir and writing pid. */ }
166
+ if (Date.now() >= deadline)
167
+ throw new Error("hq-session: timed out waiting for company lock update");
168
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
169
+ }
170
+ }
171
+ try {
172
+ return action();
173
+ }
174
+ finally {
175
+ fs.rmSync(lock, { recursive: true, force: true });
176
+ }
146
177
  }
147
178
  function findMetaValue(contents, key) {
148
179
  for (const line of contents.split("\n")) {
@@ -155,6 +186,50 @@ function findMetaValue(contents, key) {
155
186
  return { found: false, value: "" };
156
187
  }
157
188
  function getMetaValue(contents, key) { return findMetaValue(contents, key).value; }
189
+ function writeCompanyMetadata(contents, companies) {
190
+ const primary = companies[0] ?? "";
191
+ const records = contents.split("\n");
192
+ if (records.at(-1) === "")
193
+ records.pop();
194
+ const replaced = [];
195
+ let havePrimary = false;
196
+ let haveCompanies = false;
197
+ for (const line of records) {
198
+ const first = line.match(/^\s*(\S+)/)?.[1] ?? "";
199
+ if (first === "company_slug:") {
200
+ if (!havePrimary)
201
+ replaced.push(`company_slug: ${primary}`);
202
+ havePrimary = true;
203
+ }
204
+ else if (first === "company_slugs:") {
205
+ if (!haveCompanies)
206
+ replaced.push(`company_slugs: ${companies.join(",")}`);
207
+ haveCompanies = true;
208
+ }
209
+ else
210
+ replaced.push(line);
211
+ }
212
+ if (!havePrimary)
213
+ replaced.push(`company_slug: ${primary}`);
214
+ if (!haveCompanies)
215
+ replaced.push(`company_slugs: ${companies.join(",")}`);
216
+ return `${replaced.join("\n")}\n`;
217
+ }
218
+ function metaCompanySlugs(contents) {
219
+ const listed = getMetaValue(contents, "company_slugs");
220
+ const primary = getMetaValue(contents, "company_slug");
221
+ const candidates = listed ? listed.split(",") : primary ? [primary] : [];
222
+ const seen = new Set();
223
+ const result = candidates.filter((slug) => {
224
+ if (!/^[A-Za-z0-9_-]+$/.test(slug) || seen.has(slug))
225
+ return false;
226
+ seen.add(slug);
227
+ return true;
228
+ });
229
+ if (result.length > 1 && result.includes("personal"))
230
+ return primary ? [primary] : [];
231
+ return primary && result[0] !== primary ? [primary] : result;
232
+ }
158
233
  const WORK_MESH_SESSION_START_COMMAND = 'event=$HQ_CORE_SESSION_EVENT_JSON; unset HQ_CORE_SESSION_EVENT_JSON; printf "%s" "$event" | bash "$1" SessionStart';
159
234
  function recordWorkMeshSessionStart(input) {
160
235
  const hook = path.join(input.root, "core", "hooks", "SessionStart", "35-work-mesh-session-start.sh");
@@ -218,8 +293,33 @@ function runWorkMeshProjectRebind(input) {
218
293
  }
219
294
  return { stdout: result.stdout, stderr: result.stderr };
220
295
  }
296
+ function registerWorkMeshCompany(root, sessionId, company, metadata, cwd, deps) {
297
+ const library = path.join(root, "core", "scripts", "lib");
298
+ if (!deps.isFile(path.join(library, "work-mesh-enqueue.sh")) ||
299
+ !deps.isFile(path.join(library, "work-mesh-live-rebind.sh")))
300
+ return { stdout: "", stderr: "" };
301
+ const script = [
302
+ '. "$1/work-mesh-enqueue.sh" 2>/dev/null || true',
303
+ '. "$1/work-mesh-live-hook.sh" 2>/dev/null || true',
304
+ '. "$1/work-mesh-live-rebind.sh" 2>/dev/null || true',
305
+ 'if command -v work_mesh_live_write_binding_marker >/dev/null 2>&1; then work_mesh_live_write_binding_marker "$2" "$3" "$4" "$5" || true; fi',
306
+ ].join("\n");
307
+ const result = deps.runCommand("bash", ["-c", script, "_", library, sessionId, company,
308
+ getMetaValue(metadata, "project"), getMetaValue(metadata, "task")], cwd, {
309
+ ...deps.env,
310
+ HQ_ROOT: root,
311
+ PWD: deps.env.PWD || cwd,
312
+ });
313
+ if (result.error) {
314
+ const code = result.error.code;
315
+ if (code === "ENOENT" || code === "ENOTDIR")
316
+ return { stdout: "", stderr: "" };
317
+ throw result.error;
318
+ }
319
+ return { stdout: result.stdout, stderr: result.stderr };
320
+ }
221
321
  function recordHqSessionWorkMeshEffects(input, deps) {
222
- if (input.key === "company_slug" && input.value && input.value !== input.previous && input.value !== "personal") {
322
+ if ((input.key === "company_slug" || input.key === "company_slugs") && input.value && input.value !== input.previous && input.value !== "personal") {
223
323
  const stderr = recordWorkMeshSessionStart({
224
324
  sessionId: input.sessionId,
225
325
  root: input.root,
@@ -443,14 +543,18 @@ function atomicWrite(filePath, contents, deps) {
443
543
  throw error;
444
544
  }
445
545
  }
446
- function mintScopeCapability(root, sessionId, company, deps) {
447
- if (!root || !sessionId || !company || !/^[A-Za-z0-9_-]+$/.test(company))
546
+ function mintScopeCapability(root, sessionId, companies, deps, includeSet = false) {
547
+ const company = companies[0] ?? "";
548
+ if (!root || !sessionId || !company || companies.some((slug) => !/^[A-Za-z0-9_-]+$/.test(slug)))
448
549
  return false;
449
550
  const directory = path.join(root, "workspace", "sessions", sessionId);
450
551
  const filePath = path.join(directory, "scope-capability.json");
451
552
  try {
452
553
  deps.mkdir(directory);
453
- const value = { session_id: sessionId, company_slug: company, minted_at: timestamp(deps.now()) };
554
+ const mintedAt = timestamp(deps.now());
555
+ const value = includeSet
556
+ ? { session_id: sessionId, company_slug: company, minted_at: mintedAt, company_slugs: companies }
557
+ : { session_id: sessionId, company_slug: company, minted_at: mintedAt };
454
558
  atomicWrite(filePath, `${JSON.stringify(value, null, 2)}\n`, deps);
455
559
  return true;
456
560
  }
@@ -489,7 +593,7 @@ export function executeHqSession(argv, context, overrides = {}) {
489
593
  if (!subcommand || subcommand === "-h" || subcommand === "--help" || subcommand === "help") {
490
594
  return { code: 0, stdout: HQ_SESSION_HELP, stderr: "" };
491
595
  }
492
- if (!["current", "path", "get", "set"].includes(subcommand)) {
596
+ if (!["current", "path", "get", "set", "add", "remove"].includes(subcommand)) {
493
597
  return { code: 1, stdout: "", stderr: `hq-session: unknown subcommand '${subcommand}'\n${HQ_SESSION_HELP}` };
494
598
  }
495
599
  if (subcommand === "current") {
@@ -505,7 +609,15 @@ export function executeHqSession(argv, context, overrides = {}) {
505
609
  if (!metaPath || !deps.isFile(metaPath))
506
610
  return { code: 0, stdout: "", stderr: "" };
507
611
  try {
508
- const result = findMetaValue(deps.readFile(metaPath, "utf8"), key);
612
+ const contents = deps.readFile(metaPath, "utf8");
613
+ if (key === "company_slugs") {
614
+ const primary = getMetaValue(contents, "company_slug");
615
+ const slugs = deps.multiCompanyLockEnabled
616
+ ? metaCompanySlugs(contents).filter((slug) => slug === "personal" || exactCompanyDir(root, slug, deps))
617
+ : primary ? [primary] : [];
618
+ return { code: 0, stdout: slugs.length ? `${slugs.join(",")}\n` : "", stderr: "" };
619
+ }
620
+ const result = findMetaValue(contents, key);
509
621
  return { code: 0, stdout: result.found ? `${result.value}\n` : "", stderr: "" };
510
622
  }
511
623
  catch (error) {
@@ -517,10 +629,71 @@ export function executeHqSession(argv, context, overrides = {}) {
517
629
  throw error;
518
630
  }
519
631
  }
632
+ if (subcommand === "add" || subcommand === "remove") {
633
+ const kind = args[0] ?? "";
634
+ const slug = args[1] ?? "";
635
+ if (kind !== "company" || args.length < 2 || !slug) {
636
+ return { code: 1, stdout: "", stderr: `usage: hq-session.sh ${subcommand} company <slug>\n` };
637
+ }
638
+ if (subcommand === "add" && !deps.multiCompanyLockEnabled) {
639
+ return { code: 1, stdout: "", stderr: "hq-session: adding a company is unavailable because hooks.multi-company-session-lock is off\n" };
640
+ }
641
+ if (!/^[A-Za-z0-9_-]+$/.test(slug)) {
642
+ return { code: 1, stdout: "", stderr: `hq-session: invalid company_slug: '${slug}' (allowed characters: A-Z a-z 0-9 - _)\n` };
643
+ }
644
+ const dir = path.join(root, "companies", slug);
645
+ if (subcommand === "add" && (slug === "personal" || !isRegisteredCompany(root, slug, deps) || deps.isSymlink(dir) || hasCompanyCaseAlias(root, slug, deps) || !exactCompanyDir(root, slug, deps))) {
646
+ return { code: 1, stdout: "", stderr: `hq-session: company_slug must use the exact real directory name: '${slug}'\n` };
647
+ }
648
+ const sessionId = sessionOverride || resolveSessionId(root, deps);
649
+ if (!sessionId)
650
+ return { code: 1, stdout: "", stderr: "hq-session: no current session — pass --session-id <id> to target a session explicitly.\n" };
651
+ deps.mkdir(path.join(root, "workspace", "sessions", sessionId));
652
+ return withCompanyLock(root, sessionId, () => {
653
+ const target = path.join(root, "workspace", "sessions", sessionId, "meta.yaml");
654
+ try {
655
+ deps.mkdir(path.dirname(target));
656
+ const existed = deps.isFile(target);
657
+ const oldText = existed ? deps.readFile(target, "utf8") : `session_id: ${sessionId}\nstarted_at: "${timestamp(deps.now())}"\nsenior: user\n`;
658
+ const rawCompanies = metaCompanySlugs(oldText);
659
+ const companies = rawCompanies.filter((company) => company === "personal" || exactCompanyDir(root, company, deps));
660
+ if (subcommand === "add" && companies.includes(slug))
661
+ return { code: 0, stdout: "", stderr: "" };
662
+ const next = subcommand === "add"
663
+ ? [...(companies[0] === "personal" ? [] : companies), slug]
664
+ : companies.filter((value) => value !== slug);
665
+ if (!next.length)
666
+ return { code: 1, stdout: "", stderr: "hq-session: cannot remove the last locked company; use set company_slug <slug> instead\n" };
667
+ if (subcommand === "remove" && !rawCompanies.includes(slug))
668
+ return { code: 0, stdout: "", stderr: "" };
669
+ const updatedText = writeCompanyMetadata(oldText, next);
670
+ atomicWrite(target, updatedText, deps);
671
+ if (!mintScopeCapability(root, sessionId, next, deps, true)) {
672
+ if (existed)
673
+ atomicWrite(target, oldText, deps);
674
+ else
675
+ deps.remove(target);
676
+ return { code: 1, stdout: "", stderr: `hq-session: failed to mint scope-capability for session ${sessionId}; metadata was restored\n` };
677
+ }
678
+ const stdout = subcommand === "add" ? emitHardPolicies(root, slug, deps) : "";
679
+ const primaryChanged = next[0] !== companies[0];
680
+ const workMeshOutput = primaryChanged
681
+ ? registerWorkMeshCompany(root, sessionId, next[0], updatedText, context.cwd, deps)
682
+ : { stdout: "", stderr: "" };
683
+ return { code: 0, stdout: stdout + workMeshOutput.stdout, stderr: workMeshOutput.stderr };
684
+ }
685
+ catch (error) {
686
+ const message = error instanceof Error ? error.message : String(error);
687
+ return { code: 1, stdout: "", stderr: `${message}\n` };
688
+ }
689
+ });
690
+ }
520
691
  const key = args[0] ?? "";
521
692
  const value = args[1] ?? "";
522
693
  if (!key || args.length < 2)
523
694
  return { code: 1, stdout: "", stderr: "usage: hq-session.sh set <key> <value>\n" };
695
+ if (key === "company_slugs")
696
+ return { code: 1, stdout: "", stderr: "hq-session: set company_slugs is unsupported; use add/remove company to update the validated lock set\n" };
524
697
  if (key === "company_slug" && value) {
525
698
  if (!/^[A-Za-z0-9_-]+$/.test(value)) {
526
699
  return { code: 1, stdout: "", stderr: `hq-session: invalid company_slug: '${value}' (allowed characters: A-Z a-z 0-9 - _)\n` };
@@ -548,35 +721,39 @@ export function executeHqSession(argv, context, overrides = {}) {
548
721
  const initial = `session_id: ${sessionId}\nstarted_at: "${timestamp(deps.now())}"\nsenior: user\n`;
549
722
  deps.writeFile(target, initial, 0o666 & ~deps.umask());
550
723
  }
551
- let previous = "";
552
- try {
553
- previous = getMetaValue(deps.readFile(target, "utf8"), key);
554
- }
555
- catch {
556
- previous = "";
557
- }
558
724
  const oldText = deps.readFile(target, "utf8");
725
+ const previous = getMetaValue(oldText, key);
726
+ const companySetChanged = key === "company_slug" && value !== ""
727
+ && JSON.stringify(metaCompanySlugs(oldText)) !== JSON.stringify([value]);
559
728
  const records = oldText.split("\n");
560
729
  if (records.at(-1) === "")
561
730
  records.pop();
731
+ const clearProjectTask = key === "company_slug" && previous !== "" && value !== previous;
562
732
  let found = false;
563
- const replaced = records.map((line) => {
733
+ const replaced = [];
734
+ for (const line of records) {
564
735
  const first = line.match(/^\s*(\S+)/)?.[1] ?? "";
565
- if (first !== `${key}:`)
566
- return line;
567
- found = true;
568
- return `${key}: ${value}`;
569
- });
736
+ if (clearProjectTask && (first === "project:" || first === "task:"))
737
+ continue;
738
+ if (first === `${key}:`) {
739
+ found = true;
740
+ replaced.push(`${key}: ${value}`);
741
+ }
742
+ else
743
+ replaced.push(line);
744
+ }
570
745
  if (!found)
571
746
  replaced.push(`${key}: ${value}`);
572
- const updatedText = `${replaced.join("\n")}\n`;
747
+ let updatedText = `${replaced.join("\n")}\n`;
748
+ if (key === "company_slug")
749
+ updatedText = writeCompanyMetadata(updatedText, value ? [value] : []);
573
750
  atomicWrite(target, updatedText, deps);
574
751
  let stdout = "";
575
- if (key === "company_slug" && value && value !== previous) {
576
- if (!mintScopeCapability(root, sessionId, value, deps)) {
752
+ if (key === "company_slug" && value && companySetChanged) {
753
+ if (!mintScopeCapability(root, sessionId, [value], deps, true)) {
577
754
  return { code: 1, stdout: "", stderr: `hq-session: failed to mint scope-capability for session ${sessionId}\n` };
578
755
  }
579
- if (value !== "personal")
756
+ if (value !== previous && value !== "personal")
580
757
  stdout = emitHardPolicies(root, value, deps);
581
758
  }
582
759
  const workMeshOutput = recordHqSessionWorkMeshEffects({
@@ -597,7 +774,20 @@ export function executeHqSession(argv, context, overrides = {}) {
597
774
  }
598
775
  /** Signature consumed by the lightweight fast-core dispatcher. */
599
776
  export async function runHqSessionFast(argv, _stdin, cwd, hqRoot) {
600
- const result = executeHqSession(argv, { cwd, hqRoot });
777
+ const commandArgs = argv[0] === "--session-id"
778
+ ? argv.slice(2)
779
+ : argv[0]?.startsWith("--session-id=") ? argv.slice(1) : argv;
780
+ const needsMultiCompanyFlag = commandArgs[0] === "add"
781
+ || (commandArgs[0] === "get" && commandArgs[1] === "company_slugs");
782
+ let enabled = false;
783
+ if (needsMultiCompanyFlag) {
784
+ const [{ fastFlagEnabled }, { MULTI_COMPANY_SESSION_LOCK_FLAG }] = await Promise.all([
785
+ import("../native-fast-route.js"),
786
+ import("../lib/cli-flag-keys.js"),
787
+ ]);
788
+ enabled = await fastFlagEnabled(MULTI_COMPANY_SESSION_LOCK_FLAG);
789
+ }
790
+ const result = executeHqSession(argv, { cwd, hqRoot }, { multiCompanyLockEnabled: enabled });
601
791
  if (result.stdout)
602
792
  process.stdout.write(result.stdout);
603
793
  if (result.stderr)
@@ -23,6 +23,7 @@ export type ResolveCompanyDependencies = {
23
23
  onHqExit?: (args: readonly string[]) => void;
24
24
  pid?: number;
25
25
  random?: () => number;
26
+ multiCompanyLockEnabled?: boolean;
26
27
  };
27
28
  export declare function resolveCompany(args: readonly string[], dependencies: ResolveCompanyDependencies): Promise<ResolveCompanyResult>;
28
29
  export declare function runResolveCompanyFast(args: readonly string[], stdin: string, cwd: string, hqRoot: string | undefined, _commandName?: string, _hqRootArg?: string, stdinReader?: () => Promise<string>): Promise<number>;
@@ -4,6 +4,7 @@ import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { awkCannotOpenFileDiagnostic } from "../lib/core-utils/awk-diagnostics.js";
6
6
  import { buildCmdExeSpawnPlan, isWindowsBatchFile } from "../utils/windows-spawn.js";
7
+ import { fastFlagEnabled } from "../native-fast-route.js";
7
8
  export const RESOLVE_COMPANY_ASSET = "core/scripts/resolve-company.sh";
8
9
  export const RESOLVE_COMPANY_HELP = [
9
10
  "hq-core: public",
@@ -143,7 +144,7 @@ function sessionId(root, env, fileSystem) {
143
144
  const current = readFile(path.join(root, "workspace/sessions/.current"), fileSystem)?.replace(/[ \t\n\v\f\r]/g, "") ?? "";
144
145
  return validSessionId(current) ? current : "";
145
146
  }
146
- function sessionCompany(root, env, fileSystem) {
147
+ function sessionCompany(root, env, fileSystem, multiCompanyLockEnabled = false) {
147
148
  const helper = path.join(root, "core/scripts/hq-session.sh");
148
149
  try {
149
150
  fileSystem.accessSync(helper, fs.constants.X_OK);
@@ -151,19 +152,19 @@ function sessionCompany(root, env, fileSystem) {
151
152
  catch (error) {
152
153
  const code = errorCode(error);
153
154
  if (code === "ENOENT" || code === "ENOTDIR" || code === "EACCES" || code === "ELOOP")
154
- return "";
155
+ return { company: "", companies: [] };
155
156
  throw error;
156
157
  }
157
158
  // A directory may be executable according to access(2), but `bash directory`
158
159
  // fails without output, just as hq-session.sh does here.
159
160
  if (!isFile(helper, fileSystem))
160
- return "";
161
+ return { company: "", companies: [] };
161
162
  const id = sessionId(root, env, fileSystem);
162
163
  if (!id)
163
- return "";
164
+ return { company: "", companies: [] };
164
165
  const meta = readFile(path.join(root, "workspace/sessions", id, "meta.yaml"), fileSystem);
165
166
  if (meta === undefined)
166
- return "";
167
+ return { company: "", companies: [] };
167
168
  let value = "";
168
169
  for (const line of meta.split("\n")) {
169
170
  const firstField = line.replace(/^[ \t]+/, "").split(/[ \t]+/, 1)[0];
@@ -174,7 +175,30 @@ function sessionCompany(root, env, fileSystem) {
174
175
  }
175
176
  // The shell's command substitution strips trailing newlines, then tr removes
176
177
  // all remaining whitespace and double quotes from the helper's output.
177
- return value.replace(/[ \t\n\v\f\r"]/g, "");
178
+ value = value.replace(/[ \t\n\v\f\r"]/g, "");
179
+ if (!value || !multiCompanyLockEnabled)
180
+ return { company: value, companies: value ? [value] : [] };
181
+ let listed = "";
182
+ for (const line of meta.split("\n")) {
183
+ if (line.replace(/^[ \t]+/, "").split(/[ \t]+/, 1)[0] !== "company_slugs:")
184
+ continue;
185
+ listed = line.replace(/^[^:]+:[ \t]*/, "").replace(/^"|"$/g, "");
186
+ break;
187
+ }
188
+ const companies = [...new Set((listed ? listed.split(",") : [value]).filter((slug) => /^[A-Za-z0-9_-]+$/.test(slug) &&
189
+ (slug === "personal" || (() => {
190
+ try {
191
+ return fileSystem.statSync(path.join(root, "companies", slug)).isDirectory() && !fileSystem.lstatSync(path.join(root, "companies", slug)).isSymbolicLink();
192
+ }
193
+ catch {
194
+ return false;
195
+ }
196
+ })())))];
197
+ if (companies[0] !== value)
198
+ return { company: "", companies: [] };
199
+ if (companies.length > 1 && companies.includes("personal"))
200
+ return { company: value, companies: [value] };
201
+ return { company: value, companies };
178
202
  }
179
203
  function fleetIdentityPresent(rootPrefix, env, fileSystem) {
180
204
  if (env.HQ_AGENT_WORKDIR || env.HQ_AGENT_COMPANY_DIR || env.HQ_AGENT_IDENTITY_FILE)
@@ -386,8 +410,9 @@ function runHq(args, env, timeoutMs, cwd, command = "hq", onExitObserved) {
386
410
  });
387
411
  });
388
412
  }
389
- function output(company, source) {
390
- return { code: 0, stdout: Buffer.from(`{"company":"${company}","source":"${source}"}\n`), stderr: Buffer.alloc(0) };
413
+ function output(company, source, companies) {
414
+ const payload = { company, source, ...(source === "session" && companies ? { companies } : {}) };
415
+ return { code: 0, stdout: Buffer.from(`${JSON.stringify(payload)}\n`), stderr: Buffer.alloc(0) };
391
416
  }
392
417
  function parseArgs(args) {
393
418
  let root = "";
@@ -519,9 +544,13 @@ export async function resolveCompany(args, dependencies) {
519
544
  const promptMatch = promptSlug(prompt, slugs);
520
545
  if (promptMatch)
521
546
  return output(promptMatch, "prompt");
522
- const sessionMatch = sessionCompany(root, env, fileSystem);
523
- if (sessionMatch && knownSlugs.has(sessionMatch))
524
- return output(sessionMatch, "session");
547
+ const sessionMatch = sessionCompany(root, env, fileSystem, dependencies.multiCompanyLockEnabled);
548
+ if (sessionMatch.company && knownSlugs.has(sessionMatch.company)) {
549
+ const companies = sessionMatch.companies.filter((slug) => knownSlugs.has(slug));
550
+ return dependencies.multiCompanyLockEnabled
551
+ ? output(sessionMatch.company, "session", companies.length ? companies : [sessionMatch.company])
552
+ : output(sessionMatch.company, "session");
553
+ }
525
554
  if (fleetIdentityPresent(env.HQ_AGENT_ROOT_PREFIX ?? "", env, fileSystem))
526
555
  return output("", "none");
527
556
  // jq is intentionally no longer needed. The native route is always hosted by
@@ -574,6 +603,7 @@ export async function runResolveCompanyFast(args, stdin, cwd, hqRoot, _commandNa
574
603
  hqRoot,
575
604
  stdin: actualStdin,
576
605
  stdinIsTty: process.stdin.isTTY === true,
606
+ multiCompanyLockEnabled: await fastFlagEnabled("hooks.multi-company-session-lock"),
577
607
  });
578
608
  process.stdout.write(result.stdout);
579
609
  if (result.stderr.length)
@@ -4,6 +4,8 @@ import { collectFeedbackLogs, feedbackLogsEnabled } from "../utils/feedback-logs
4
4
  import { type LogBundleRef } from "../utils/feedback-log-bundle.js";
5
5
  export declare const FEEDBACK_LATEST_VERSION_FLAG = "feedback.latest-version";
6
6
  export declare const FEEDBACK_LATEST_VERSION_FLAG_FALLBACK = false;
7
+ export declare const FEEDBACK_REPORTER_KIND_FLAG = "feedback.reporter-kind";
8
+ export declare const FEEDBACK_REPORTER_KIND_FLAG_FALLBACK = true;
7
9
  export declare const BODY_MAX_BYTES: number;
8
10
  /** Validate fields that must be valid before feedback attachments are uploaded. */
9
11
  export declare function validateFeedbackContent(title: string, body: string): void;
@@ -44,7 +46,15 @@ export interface FeedbackSubmitOptions {
44
46
  diagnostics?: DiagnosticsBlob;
45
47
  /** Precomputed setting so environment lookup happens before attachments upload. */
46
48
  logsEnabled?: boolean;
49
+ /** Optional one-way override: callers can mark human sessions as agent. */
50
+ reporterOverride?: "agent";
47
51
  }
52
+ export interface FeedbackReporterMark {
53
+ reporter_kind: "agent" | "human";
54
+ reporter_source: string;
55
+ }
56
+ /** Resolve a safe reporter classification without returning any env values. */
57
+ export declare function resolveFeedbackReporter(env?: NodeJS.ProcessEnv, isTTY?: boolean, override?: string): FeedbackReporterMark;
48
58
  export declare function readBodyFile(bodyFile: string, stdin?: NodeJS.ReadableStream): Promise<string>;
49
59
  /**
50
60
  * Attach redacted `~/.hq` log tails to `diagnostics` if, and only if, they fit
@@ -10,6 +10,8 @@ import { resolveProcessFlagGate } from "../lib/flag-registry.js";
10
10
  import { cachedLatestVersion } from "../utils/version-check.js";
11
11
  export const FEEDBACK_LATEST_VERSION_FLAG = "feedback.latest-version";
12
12
  export const FEEDBACK_LATEST_VERSION_FLAG_FALLBACK = false;
13
+ export const FEEDBACK_REPORTER_KIND_FLAG = "feedback.reporter-kind";
14
+ export const FEEDBACK_REPORTER_KIND_FLAG_FALLBACK = true;
13
15
  export const BODY_MAX_BYTES = 64 * 1024;
14
16
  /** Validate fields that must be valid before feedback attachments are uploaded. */
15
17
  export function validateFeedbackContent(title, body) {
@@ -46,6 +48,32 @@ function validateFeedbackRequestSize(requestBody) {
46
48
  * after attaching, so this reserve only needs to be roughly right.
47
49
  */
48
50
  const LOGS_ENVELOPE_RESERVE_BYTES = 1024;
51
+ const AGENT_RUNTIME_MARKERS = [
52
+ "HQ_LANE_ID",
53
+ "HQ_POLICY_WORKER_DIR",
54
+ "HQ_AGENT_DIR",
55
+ "CODEX_THREAD_ID",
56
+ "CODEX_SESSION_ID",
57
+ "CLAUDE_CODE_SESSION_ID",
58
+ "CLAUDE_SESSION_ID",
59
+ "HQ_SESSION_ID",
60
+ "GROK_SESSION_ID",
61
+ ];
62
+ /** Resolve a safe reporter classification without returning any env values. */
63
+ export function resolveFeedbackReporter(env = process.env, isTTY = process.stdin.isTTY === true, override) {
64
+ for (const marker of AGENT_RUNTIME_MARKERS) {
65
+ if (env[marker]?.trim()) {
66
+ return { reporter_kind: "agent", reporter_source: marker };
67
+ }
68
+ }
69
+ if (override === "agent") {
70
+ return { reporter_kind: "agent", reporter_source: "CLI_REPORTER_OVERRIDE" };
71
+ }
72
+ return {
73
+ reporter_kind: "human",
74
+ reporter_source: isTTY ? "stdin_isTTY" : "no_agent_runtime_marker",
75
+ };
76
+ }
49
77
  function buildFeedbackRequestBody(opts) {
50
78
  const requestBody = {
51
79
  type: opts.type,
@@ -53,6 +81,10 @@ function buildFeedbackRequestBody(opts) {
53
81
  body: opts.body,
54
82
  diagnostics: opts.diagnostics,
55
83
  };
84
+ if (opts.reporterMark) {
85
+ requestBody.reporter_kind = opts.reporterMark.reporter_kind;
86
+ requestBody.reporter_source = opts.reporterMark.reporter_source;
87
+ }
56
88
  if (opts.company) {
57
89
  requestBody.company = opts.company;
58
90
  }
@@ -124,6 +156,9 @@ export async function submitFeedback(opts) {
124
156
  // which invokes the same validator before uploading any attachments.
125
157
  validateFeedbackContent(opts.title, opts.body);
126
158
  const diagnostics = opts.diagnostics ?? collectDiagnostics();
159
+ const reporterMark = opts.type === "bug" && resolveProcessFlagGate(FEEDBACK_REPORTER_KIND_FLAG, { fallback: FEEDBACK_REPORTER_KIND_FLAG_FALLBACK }, () => FEEDBACK_REPORTER_KIND_FLAG_FALLBACK)
160
+ ? resolveFeedbackReporter(process.env, process.stdin.isTTY === true, opts.reporterOverride)
161
+ : undefined;
127
162
  const requestBody = buildFeedbackRequestBody({
128
163
  type: opts.type,
129
164
  title: opts.title,
@@ -132,6 +167,7 @@ export async function submitFeedback(opts) {
132
167
  diagnostics,
133
168
  screenshots: opts.screenshots,
134
169
  logBundle: opts.logBundle,
170
+ reporterMark,
135
171
  });
136
172
  // Direct callers can supply attachment references; ensure the complete base
137
173
  // envelope still fits before adding optional inline logs or making a request.
@@ -172,12 +208,16 @@ function registerSubcommand(feedbackCmd, type) {
172
208
  .requiredOption("--title <text>", "Short title for the report")
173
209
  .requiredOption("--body-file <path>", "Path to a markdown file with the body; use - to read from stdin")
174
210
  .option("--company <slug>", "Company slug to associate with the report")
211
+ .option("--reporter <kind>", "Mark this bug report as agent-filed (only 'agent' is accepted)")
175
212
  .option("--screenshot <path>", `Attach a screenshot (repeatable, up to ${MAX_SCREENSHOTS}; .png/.jpg/.jpeg/.webp/.gif)`, (value, prev) => [...prev, value], [])
176
213
  // Commander turns `--no-logs` into a boolean `logs` that defaults to true,
177
214
  // so the default is "attach logs" and the flag is the opt-out.
178
215
  .option("--no-logs", "Do not attach redacted tails of your ~/.hq log files (also: HQ_FEEDBACK_LOGS=0)")
179
216
  .action(async (opts) => {
180
217
  try {
218
+ if (opts.reporter !== undefined && (opts.reporter !== "agent" || type !== "bug")) {
219
+ throw new Error("Invalid --reporter value: only bug reports may use 'agent'.");
220
+ }
181
221
  const token = await ensureCognitoToken({ interactive: false });
182
222
  const body = await readBodyFile(opts.bodyFile);
183
223
  validateFeedbackContent(opts.title, body);
@@ -192,6 +232,9 @@ function registerSubcommand(feedbackCmd, type) {
192
232
  diagnostics.latestVersion = latestVersion;
193
233
  }
194
234
  const logsEnabled = opts.logs !== false && feedbackLogsEnabled();
235
+ const reporterMark = type === "bug" && resolveProcessFlagGate(FEEDBACK_REPORTER_KIND_FLAG, { fallback: FEEDBACK_REPORTER_KIND_FLAG_FALLBACK }, () => FEEDBACK_REPORTER_KIND_FLAG_FALLBACK)
236
+ ? resolveFeedbackReporter(process.env, process.stdin.isTTY === true, opts.reporter)
237
+ : undefined;
195
238
  // Validate the serialized server request envelope before screenshots
196
239
  // or the log bundle can create remote objects. Attachment references
197
240
  // are added and checked again inside submitFeedback after upload.
@@ -201,6 +244,7 @@ function registerSubcommand(feedbackCmd, type) {
201
244
  body,
202
245
  company: opts.company,
203
246
  diagnostics,
247
+ reporterMark,
204
248
  }));
205
249
  // vaultApiFetch can reject an hqk_ token or an invalid API base URL
206
250
  // locally before fetch. Check the mandatory POST, and the screenshot
@@ -236,6 +280,7 @@ function registerSubcommand(feedbackCmd, type) {
236
280
  includeLogs: opts.logs !== false,
237
281
  logBundle,
238
282
  diagnostics,
283
+ reporterOverride: opts.reporter === "agent" ? "agent" : undefined,
239
284
  logsEnabled,
240
285
  });
241
286
  console.log(`Submitted: ${result.id}`);