@indigoai-us/hq-cli 5.345.47 → 5.345.48

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.
@@ -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)
@@ -2,6 +2,11 @@ import type { Command } from "commander";
2
2
  export { buildSecretUsage, emitEnvExportsAndExit, loadRevealedSecrets, parseSecretNameList, runSecretsEnvCommand, } from "../secrets-env-action.js";
3
3
  export type { SecretLoadResponse, SecretScriptLockMode, SecretTier, SecretUsage, SecretUsageChannel } from "../secrets-env-action.js";
4
4
  export type { SecretScriptUsage, SecretMetadata, SecretLoadSuccessRow } from "../secrets-env-action.js";
5
+ type SessionCompanyLock = {
6
+ sessionId: string;
7
+ companies: string[];
8
+ } | undefined;
9
+ export declare function readSessionCompanyLock(env?: NodeJS.ProcessEnv): SessionCompanyLock;
5
10
  import { vaultApiFetch, getCompanyUid, getEntityUid } from "../utils/vault-api.js";
6
11
  export type { VaultApiOptions } from "../utils/vault-api.js";
7
12
  export { vaultApiFetch, getCompanyUid, getEntityUid };