@indigoai-us/hq-cli 5.121.2 → 5.122.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Fixed
6
+
7
+ - Work Mesh now keeps the company you selected when its session context is
8
+ acknowledged or recovered after a restart. Mesh activity stays attached to
9
+ the right company instead of being held for missing attribution.
10
+
11
+ ## [5.122.0] — 2026-09-18
12
+
13
+ ### Added
14
+
15
+ - A bot can now join HQ from the computer its owner already uses. Before,
16
+ `hq agent enroll` refused on such a machine — the owner is signed in there,
17
+ and an agent identity must not share a host account with a person's login —
18
+ which left two bad options: a second user account, or `--replace`.
19
+
20
+ Enrollment now moves the bot into its own directory instead of refusing:
21
+ `~/.hq-agent/<name>/`, with its own credentials, key, state and logs. The
22
+ owner's session file is never read, moved, or changed. `hq agent probe`,
23
+ `hq agent kit`, `hq agent inbox` and `hq agent mcp` all find that directory
24
+ on their own, so the bot is itself without anyone exporting environment
25
+ variables. Use `--name <bot>` to choose the directory, or to run a second
26
+ bot on the same computer.
27
+
28
+ Two or more local bots are deliberately ambiguous: `hq agent` commands then
29
+ ask for `HQ_AGENT_DIR` rather than guessing which identity to act as.
30
+
5
31
  ## [5.121.2] — 2026-09-18
6
32
 
7
33
  ### Added
@@ -4271,6 +4271,9 @@ export declare const COMMAND_CATALOG: readonly [{
4271
4271
  }, {
4272
4272
  readonly flags: "--replace";
4273
4273
  readonly description: "Overwrite an existing machine identity on this host";
4274
+ }, {
4275
+ readonly flags: "--name <bot>";
4276
+ readonly description: "Enroll into ~/.hq-agent/<bot>/ — for a bot sharing a computer with its owner";
4274
4277
  }, {
4275
4278
  readonly flags: "--api-base-url <url>";
4276
4279
  readonly description: "hq-pro control plane (default: HQ_VAULT_API_URL or production)";
@@ -5747,7 +5750,7 @@ export declare const COMMAND_CATALOG: readonly [{
5747
5750
  readonly subcommands: readonly [];
5748
5751
  }, {
5749
5752
  readonly name: "set";
5750
- readonly description: "Set the device default company slug (unlocked when migration is true for every membership)";
5753
+ readonly description: "Set the device default company slug after verifying membership";
5751
5754
  readonly aliases: readonly [];
5752
5755
  readonly hidden: false;
5753
5756
  readonly usage: "[options] <slug>";
@@ -5758,10 +5761,10 @@ export declare const COMMAND_CATALOG: readonly [{
5758
5761
  }];
5759
5762
  readonly options: readonly [{
5760
5763
  readonly flags: "--allow-without-migration";
5761
- readonly description: "Bypass DEFAULT_COMPANY_LOCKED with a warning";
5764
+ readonly description: "Deprecated compatibility no-op; selected-company membership is always verified";
5762
5765
  }, {
5763
5766
  readonly flags: "--company <slug|uid>";
5764
- readonly description: "Deprecated no-op; unlock probes every active membership";
5767
+ readonly description: "Deprecated no-op; the selected company is verified directly";
5765
5768
  }, {
5766
5769
  readonly flags: "--json";
5767
5770
  readonly description: "Print machine-readable JSON";
@@ -5529,6 +5529,10 @@ export const COMMAND_CATALOG = [
5529
5529
  "flags": "--replace",
5530
5530
  "description": "Overwrite an existing machine identity on this host"
5531
5531
  },
5532
+ {
5533
+ "flags": "--name <bot>",
5534
+ "description": "Enroll into ~/.hq-agent/<bot>/ — for a bot sharing a computer with its owner"
5535
+ },
5532
5536
  {
5533
5537
  "flags": "--api-base-url <url>",
5534
5538
  "description": "hq-pro control plane (default: HQ_VAULT_API_URL or production)"
@@ -7414,7 +7418,7 @@ export const COMMAND_CATALOG = [
7414
7418
  },
7415
7419
  {
7416
7420
  "name": "set",
7417
- "description": "Set the device default company slug (unlocked when migration is true for every membership)",
7421
+ "description": "Set the device default company slug after verifying membership",
7418
7422
  "aliases": [],
7419
7423
  "hidden": false,
7420
7424
  "usage": "[options] <slug>",
@@ -7428,11 +7432,11 @@ export const COMMAND_CATALOG = [
7428
7432
  "options": [
7429
7433
  {
7430
7434
  "flags": "--allow-without-migration",
7431
- "description": "Bypass DEFAULT_COMPANY_LOCKED with a warning"
7435
+ "description": "Deprecated compatibility no-op; selected-company membership is always verified"
7432
7436
  },
7433
7437
  {
7434
7438
  "flags": "--company <slug|uid>",
7435
- "description": "Deprecated no-op; unlock probes every active membership"
7439
+ "description": "Deprecated no-op; the selected company is verified directly"
7436
7440
  },
7437
7441
  {
7438
7442
  "flags": "--json",
@@ -88,7 +88,29 @@ export interface EnrollOptions {
88
88
  company?: string;
89
89
  replace?: boolean;
90
90
  apiBaseUrl?: string;
91
+ /** Enroll into `~/.hq-agent/<name>/` — a bot sharing a host with a person. */
92
+ name?: string;
91
93
  }
94
+ /**
95
+ * Where this enrollment should write, given what already lives on the host.
96
+ *
97
+ * A bot that runs on its owner's own computer is the common case, not an
98
+ * error: the machine has the owner's HQ session, and the two identities have
99
+ * to coexist. So an unpinned enrollment onto such a host does NOT refuse and
100
+ * does NOT overwrite anything — it moves into `~/.hq-agent/<name>/`, its own
101
+ * tree with its own credentials, key, state and logs. The person's session
102
+ * file is neither read nor touched, and `hq agent …` finds the bot's tree on
103
+ * its own afterwards (see agentDir).
104
+ */
105
+ export declare function resolveEnrollTarget(opts: {
106
+ name?: string;
107
+ replace?: boolean;
108
+ }, env?: NodeJS.ProcessEnv, home?: string): {
109
+ paths: AgentKitPaths;
110
+ local: boolean;
111
+ };
112
+ /** Lowercase slug, so the directory name can never escape `~/.hq-agent`. */
113
+ export declare function validateLocalAgentName(name: string): string;
92
114
  export interface EnrollResult {
93
115
  agentUid: string;
94
116
  companySlug: string;
@@ -98,6 +120,10 @@ export interface EnrollResult {
98
120
  hostKeyPath: string;
99
121
  /** Set when --company disagreed with the server's company. */
100
122
  companyMismatch?: string;
123
+ /** True when this enrolled beside a person's session in its own directory. */
124
+ local: boolean;
125
+ /** The tree this identity lives in. */
126
+ agentDir: string;
101
127
  }
102
128
  /** Pure-ish orchestration so tests can drive it without a TTY or network. */
103
129
  export declare function enrollHost(opts: EnrollOptions, deps?: EnrollDeps): Promise<EnrollResult>;
@@ -22,7 +22,7 @@ import { DEFAULT_VAULT_API_URL, personTokenCacheFile, } from "../utils/cognito-s
22
22
  import { HQ_CLIENT_NAME } from "../utils/vault-api.js";
23
23
  import { networkTransportErrorCode } from "../utils/network-transport-error.js";
24
24
  import { generateHostKeyPair, machineCredsFileExists, readExternalMachineCreds, writeHostKeyPair, writeMachineCreds, } from "../lib/agent-kit/creds.js";
25
- import { agentKitPaths } from "../lib/agent-kit/paths.js";
25
+ import { DEFAULT_LOCAL_AGENT_NAME, agentKitPaths, localAgentDir, } from "../lib/agent-kit/paths.js";
26
26
  export const ENROLL_PATH = "/v1/agents/enroll";
27
27
  /** Canonical code: un-grouped, uppercase (server hashes exactly this). */
28
28
  export function normalizeEnrollmentCode(raw) {
@@ -63,9 +63,14 @@ export function refusalMessage(existing) {
63
63
  case "none":
64
64
  return null;
65
65
  case "human":
66
+ // Reached only when a caller pins the tree (HQ_AGENT_DIR / --dir) at a
67
+ // home that would sit on top of a person's login. The unpinned path
68
+ // enrolls into a sibling directory instead of refusing — see
69
+ // resolveEnrollTarget.
66
70
  return (`A human HQ session exists at ${existing.file}. An agent identity must not ` +
67
- `share a host account with a person's login. Run enrollment under a dedicated ` +
68
- `user (or container), or pass --replace to enroll anyway — the human session ` +
71
+ `share a host account with a person's login. Enroll into a directory of its ` +
72
+ `own (drop HQ_AGENT_DIR and this happens automatically, or pass ` +
73
+ `--name <bot>), or pass --replace to enroll here anyway — the human session ` +
69
74
  `file is left untouched and never read.`);
70
75
  case "machine":
71
76
  return (`This host already has a machine identity at ${existing.file}` +
@@ -169,10 +174,49 @@ function parseEnrollResponse(raw) {
169
174
  },
170
175
  };
171
176
  }
177
+ /**
178
+ * Where this enrollment should write, given what already lives on the host.
179
+ *
180
+ * A bot that runs on its owner's own computer is the common case, not an
181
+ * error: the machine has the owner's HQ session, and the two identities have
182
+ * to coexist. So an unpinned enrollment onto such a host does NOT refuse and
183
+ * does NOT overwrite anything — it moves into `~/.hq-agent/<name>/`, its own
184
+ * tree with its own credentials, key, state and logs. The person's session
185
+ * file is neither read nor touched, and `hq agent …` finds the bot's tree on
186
+ * its own afterwards (see agentDir).
187
+ */
188
+ export function resolveEnrollTarget(opts, env = process.env, home = os.homedir()) {
189
+ const pinned = Boolean(env.HQ_AGENT_DIR?.trim() || env.HQ_MACHINE_CREDS_FILE?.trim());
190
+ if (opts.name) {
191
+ const dir = localAgentDir(validateLocalAgentName(opts.name), home);
192
+ return { paths: agentKitPaths(home, { ...env, HQ_AGENT_DIR: dir }), local: true };
193
+ }
194
+ const base = agentKitPaths(home, env);
195
+ // A pinned tree is the caller's explicit choice; --replace is an explicit
196
+ // choice too. Neither gets silently redirected.
197
+ if (pinned || opts.replace)
198
+ return { paths: base, local: false };
199
+ if (detectExistingIdentity(base, env).kind !== "human") {
200
+ return { paths: base, local: false };
201
+ }
202
+ const dir = localAgentDir(DEFAULT_LOCAL_AGENT_NAME, home);
203
+ return { paths: agentKitPaths(home, { ...env, HQ_AGENT_DIR: dir }), local: true };
204
+ }
205
+ /** Lowercase slug, so the directory name can never escape `~/.hq-agent`. */
206
+ export function validateLocalAgentName(name) {
207
+ const trimmed = (name ?? "").trim();
208
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(trimmed) || trimmed.includes("--")) {
209
+ throw new EnrollError(`Invalid --name "${name}": use 1–40 lowercase letters, digits and single hyphens (e.g. "grokbot").`);
210
+ }
211
+ return trimmed;
212
+ }
172
213
  /** Pure-ish orchestration so tests can drive it without a TTY or network. */
173
214
  export async function enrollHost(opts, deps = {}) {
174
215
  const env = deps.env ?? process.env;
175
- const paths = deps.paths ?? agentKitPaths(os.homedir(), env);
216
+ const target = deps.paths
217
+ ? { paths: deps.paths, local: false }
218
+ : resolveEnrollTarget({ name: opts.name, replace: opts.replace }, env);
219
+ const paths = target.paths;
176
220
  const doFetch = deps.fetch ?? fetch;
177
221
  const apiBaseUrl = (opts.apiBaseUrl ?? env.HQ_VAULT_API_URL ?? DEFAULT_VAULT_API_URL).replace(/\/+$/, "");
178
222
  const code = normalizeEnrollmentCode(opts.code);
@@ -224,6 +268,8 @@ export async function enrollHost(opts, deps = {}) {
224
268
  };
225
269
  writeMachineCreds(paths, creds);
226
270
  return {
271
+ local: target.local,
272
+ agentDir: paths.agentDir,
227
273
  agentUid: parsed.agentUid,
228
274
  companySlug: parsed.companySlug,
229
275
  companyUid: parsed.companyUid,
@@ -241,6 +287,7 @@ export function registerAgentEnrollCommand(agent) {
241
287
  .description("Enroll this host as an external HQ agent using a one-time code")
242
288
  .option("--company <slug>", "Expected company slug (warns when the code belongs elsewhere)")
243
289
  .option("--replace", "Overwrite an existing machine identity on this host")
290
+ .option("--name <bot>", "Enroll into ~/.hq-agent/<bot>/ — for a bot sharing a computer with its owner")
244
291
  .option("--api-base-url <url>", "hq-pro control plane (default: HQ_VAULT_API_URL or production)")
245
292
  .action(async (code, opts) => {
246
293
  try {
@@ -249,8 +296,16 @@ export function registerAgentEnrollCommand(agent) {
249
296
  company: opts.company,
250
297
  replace: opts.replace,
251
298
  apiBaseUrl: opts.apiBaseUrl,
299
+ name: opts.name,
252
300
  });
253
301
  console.log(chalk.green(`Enrolled as ${result.agentUid} in ${result.companySlug}.`));
302
+ if (result.local) {
303
+ // Say it plainly: this host belongs to a person, and the bot is a
304
+ // guest on it. Nothing of theirs was read, moved or overwritten.
305
+ console.log(chalk.dim(` This computer already has a person signed in, so this agent lives in its own\n` +
306
+ ` directory beside them: ${result.agentDir}. Their session was not read or changed.\n` +
307
+ ` Every hq agent command finds this tree on its own — no environment to set.`));
308
+ }
254
309
  console.log(` host fingerprint: ${result.hostFingerprint}`);
255
310
  console.log(` credentials: ${result.credsPath} (0600)`);
256
311
  console.log(` host key: ${result.hostKeyPath} (0600)`);
@@ -260,8 +315,10 @@ export function registerAgentEnrollCommand(agent) {
260
315
  }
261
316
  console.log("");
262
317
  console.log("Next:");
263
- console.log(" hq whoami # should report the agent identity");
264
- console.log(" hq agent kit install # sync, work-mesh, inbox and heartbeat services");
318
+ console.log(result.local
319
+ ? " hq agent probe # its first line reports this agent, not you"
320
+ : " hq whoami # should report the agent identity");
321
+ console.log(" hq agent kit install # inbox poller and heartbeat services");
265
322
  console.log(" hq agent probe # end-to-end check, reported to the console");
266
323
  }
267
324
  catch (err) {
@@ -8,6 +8,9 @@
8
8
  * hq agent mcp stdio MCP server for the bot framework
9
9
  * hq agent inbox [done <id…>] pending HQ messages / mark handled
10
10
  */
11
+ import * as os from "node:os";
12
+ import { adoptAgentIdentityEnv } from "../lib/agent-kit/adopt-identity.js";
13
+ import { agentKitPaths } from "../lib/agent-kit/paths.js";
11
14
  import { registerAgentEnrollCommand } from "./agent-enroll.js";
12
15
  import { registerAgentInboxCommand } from "./agent-inbox.js";
13
16
  import { registerAgentKitCommand } from "./agent-kit.js";
@@ -16,7 +19,18 @@ import { registerAgentProbeCommand } from "./agent-probe.js";
16
19
  export function registerAgentCommand(program) {
17
20
  const agent = program
18
21
  .command("agent")
19
- .description("Enroll and run this host as an external HQ agent");
22
+ .description("Enroll and run this host as an external HQ agent")
23
+ // A bot that shares a computer with its owner keeps its identity in its
24
+ // own directory. Publish that tree to the environment before any
25
+ // subcommand runs, so the CLI and hq-cloud both mint as the AGENT rather
26
+ // than falling back to the one fixed path — and, on a person's computer,
27
+ // to the person. Enroll is exempt: it is the command that decides where
28
+ // the tree goes.
29
+ .hook("preAction", (_thisCommand, actionCommand) => {
30
+ if (actionCommand.name() === "enroll")
31
+ return;
32
+ adoptAgentIdentityEnv(agentKitPaths(os.homedir(), process.env), process.env);
33
+ });
20
34
  registerAgentEnrollCommand(agent);
21
35
  registerAgentKitCommand(agent);
22
36
  registerAgentProbeCommand(agent);
@@ -12,7 +12,7 @@ import * as readline from "node:readline/promises";
12
12
  import { STORY_STATUSES, appendThreadEvent, callerLabelFromToken, ensureProjectThread, eventPayload, listActiveMembershipCompanies, listActiveThreads, patchStoryStatus, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
13
13
  import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
14
14
  import { clearDefaultCompany, getDefaultCompany, readDeviceConfig, recordMigrationCapabilitySnapshot, setDefaultCompany, } from "../lib/work-context/config.js";
15
- import { DefaultCompanyLockedError, DefaultCompanyUnavailableError, } from "../lib/work-context/errors.js";
15
+ import { DefaultCompanyUnavailableError, } from "../lib/work-context/errors.js";
16
16
  import { isValidSessionId } from "../lib/mesh/live/session-identity.js";
17
17
  import { CLI_KIND_TO_SCHEMA, resolveEnqueueSessionId, } from "../lib/mesh/live/index.js";
18
18
  import { flushSessionEvents } from "../lib/mesh/live/flush.js";
@@ -375,7 +375,7 @@ async function runContextDefaultGet(opts) {
375
375
  }
376
376
  async function runContextDefaultSet(slug, opts) {
377
377
  const root = workContextHomeRoot();
378
- void opts.company; // retained for CLI compatibility; unlock is memberships-wide
378
+ void opts.company; // retained for CLI compatibility
379
379
  let token;
380
380
  try {
381
381
  token = await requireToken();
@@ -383,42 +383,38 @@ async function runContextDefaultSet(slug, opts) {
383
383
  catch {
384
384
  token = undefined;
385
385
  }
386
- // US-017B: unlock only when EVERY active membership advertises migration.
386
+ // Migration remains useful diagnostic context, but it must never prevent a
387
+ // verified member from choosing their own device-local preference.
387
388
  let migrationCapability = false;
389
+ let migrationWarning;
388
390
  if (token) {
389
391
  const probe = await probeMigrationCapabilityForMemberships(token);
390
392
  recordMigrationCapabilitySnapshot(probe, { root });
391
393
  migrationCapability = probe.unlocked;
392
- if (probe.offline && !opts.allowWithoutMigration) {
393
- console.error(chalk.red("DEFAULT_COMPANY_LOCKED: cannot probe migration capability (offline or not logged in)"));
394
- process.exitCode = 1;
395
- return;
394
+ if (probe.offline) {
395
+ migrationWarning =
396
+ "Warning: migration capability could not be checked; membership for the selected company will still be verified.";
396
397
  }
397
- if (!probe.unlocked && !opts.allowWithoutMigration) {
398
+ else if (!probe.unlocked) {
398
399
  const detail = probe.companies.length === 0
399
400
  ? "no active memberships"
400
401
  : probe.companies
401
402
  .filter((c) => !c.migration)
402
403
  .map((c) => c.companySlug || c.companyUid)
403
404
  .join(", ");
404
- console.error(chalk.red(`DEFAULT_COMPANY_LOCKED: migration capability is not true for every company you belong to (${detail || "locked"})`));
405
- process.exitCode = 1;
406
- return;
405
+ migrationWarning =
406
+ `Warning: migration capability is mixed or unavailable for ${detail || "some memberships"}; ` +
407
+ "the selected company can still be set because membership is verified directly.";
407
408
  }
408
409
  }
409
- else if (!opts.allowWithoutMigration) {
410
- console.error(chalk.red("DEFAULT_COMPANY_LOCKED: cannot probe migration capability (no Cognito session)"));
411
- process.exitCode = 1;
412
- return;
413
- }
414
- if (!migrationCapability && opts.allowWithoutMigration) {
415
- console.error(chalk.yellow("Warning: --allow-without-migration bypasses DEFAULT_COMPANY_LOCKED; a wrong default is hard to correct until migration is available for every company you belong to."));
410
+ else {
411
+ migrationWarning =
412
+ "Warning: migration capability could not be checked without a Cognito session; membership for the selected company must still be verified.";
416
413
  }
417
414
  try {
418
415
  const cfg = await setDefaultCompany(slug, {
419
416
  root,
420
417
  migrationCapability,
421
- allowWithoutMigration: opts.allowWithoutMigration,
422
418
  validateMembership: async (candidate) => {
423
419
  if (!token) {
424
420
  throw new DefaultCompanyUnavailableError(`Cannot verify membership for company "${candidate}" (no Cognito session; offline or not logged in)`);
@@ -437,15 +433,16 @@ async function runContextDefaultSet(slug, opts) {
437
433
  },
438
434
  });
439
435
  if (opts.json) {
440
- console.log(JSON.stringify({ ok: true, config: cfg }, null, 2));
436
+ console.log(JSON.stringify({ ok: true, config: cfg, migrationWarning }, null, 2));
441
437
  return;
442
438
  }
443
439
  const stored = cfg.defaultCompany;
444
440
  console.log(`Default company set to ${stored?.slug ?? slug}${stored?.uid ? ` (${stored.uid})` : ""}`);
441
+ if (migrationWarning)
442
+ console.error(chalk.yellow(migrationWarning));
445
443
  }
446
444
  catch (err) {
447
- if (err instanceof DefaultCompanyLockedError ||
448
- err instanceof DefaultCompanyUnavailableError) {
445
+ if (err instanceof DefaultCompanyUnavailableError) {
449
446
  console.error(chalk.red(`${err.code}: ${err.message}`));
450
447
  process.exitCode = 1;
451
448
  return;
@@ -715,13 +712,16 @@ async function runSessionStatus(opts) {
715
712
  }
716
713
  const { token, company } = await withCompany({ company: opts.company });
717
714
  const live = await fetchCompanyLive(token, company.companyUid);
715
+ const unattributedEvents = collectDaemonDoctor().unattributedEvents;
718
716
  if (opts.json) {
719
- console.log(JSON.stringify({ ok: true, action: "session-status", company, live }, null, 2));
717
+ console.log(JSON.stringify({ ok: true, action: "session-status", company, live, unattributedEvents }, null, 2));
720
718
  return;
721
719
  }
722
720
  for (const line of formatCompanyLiveTable(live, company.companySlug || company.companyUid)) {
723
721
  console.log(line);
724
722
  }
723
+ console.log(`unattributed events: ${unattributedEvents.total} ` +
724
+ `(spool=${unattributedEvents.spool} held=${unattributedEvents.held} dead-letter=${unattributedEvents.deadLetter})`);
725
725
  }
726
726
  const HARNESSES = new Set([
727
727
  "claude-code",
@@ -1085,10 +1085,10 @@ export function registerMeshCommand(program) {
1085
1085
  .action((opts) => wrap(() => runContextDefaultGet(opts))());
1086
1086
  def
1087
1087
  .command("set")
1088
- .description("Set the device default company slug (unlocked when migration is true for every membership)")
1088
+ .description("Set the device default company slug after verifying membership")
1089
1089
  .argument("<slug>", "Company slug")
1090
- .option("--allow-without-migration", "Bypass DEFAULT_COMPANY_LOCKED with a warning")
1091
- .option("--company <slug|uid>", "Deprecated no-op; unlock probes every active membership")
1090
+ .option("--allow-without-migration", "Deprecated compatibility no-op; selected-company membership is always verified")
1091
+ .option("--company <slug|uid>", "Deprecated no-op; the selected company is verified directly")
1092
1092
  .option("--json", "Print machine-readable JSON")
1093
1093
  .action((slug, opts) => wrap(() => runContextDefaultSet(slug, opts))());
1094
1094
  def
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Make `hq agent …` act as THIS host's agent, even on a computer whose owner
3
+ * is signed in to HQ.
4
+ *
5
+ * The token layer resolves machine credentials from `HQ_MACHINE_CREDS_FILE`,
6
+ * falling back to the one fixed path `~/.hq-agent/machine-creds.json` — and
7
+ * hq-cloud's own `loadMachineCreds` reads the same variable. A bot that shares
8
+ * a computer with its owner lives in a sibling directory instead, so without
9
+ * this the CLI would find the layout of an agent and the credentials of a
10
+ * person: `hq agent probe` would report its owner's identity and the bot would
11
+ * act as them.
12
+ *
13
+ * So before any `hq agent` subcommand runs, the resolved tree is published to
14
+ * the environment. An explicit `HQ_MACHINE_CREDS_FILE` is never overwritten —
15
+ * a daemon unit that pins one stays pinned.
16
+ *
17
+ * The person's own session file is not read, moved, or changed by any of this.
18
+ */
19
+ import type { AgentKitPaths } from "./paths.js";
20
+ export declare const MACHINE_CREDS_FILE_ENV = "HQ_MACHINE_CREDS_FILE";
21
+ export declare const MACHINE_TOKEN_STATE_DIR_ENV = "HQ_MACHINE_TOKEN_STATE_DIR";
22
+ /**
23
+ * Point the token layer at `paths` unless the caller already pinned it.
24
+ * Returns the variables it set, for tests and for `--verbose` output.
25
+ */
26
+ export declare function adoptAgentIdentityEnv(paths: Pick<AgentKitPaths, "agentDir" | "machineCredsPath">, env?: NodeJS.ProcessEnv): Record<string, string>;
27
+ //# sourceMappingURL=adopt-identity.d.ts.map
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Make `hq agent …` act as THIS host's agent, even on a computer whose owner
3
+ * is signed in to HQ.
4
+ *
5
+ * The token layer resolves machine credentials from `HQ_MACHINE_CREDS_FILE`,
6
+ * falling back to the one fixed path `~/.hq-agent/machine-creds.json` — and
7
+ * hq-cloud's own `loadMachineCreds` reads the same variable. A bot that shares
8
+ * a computer with its owner lives in a sibling directory instead, so without
9
+ * this the CLI would find the layout of an agent and the credentials of a
10
+ * person: `hq agent probe` would report its owner's identity and the bot would
11
+ * act as them.
12
+ *
13
+ * So before any `hq agent` subcommand runs, the resolved tree is published to
14
+ * the environment. An explicit `HQ_MACHINE_CREDS_FILE` is never overwritten —
15
+ * a daemon unit that pins one stays pinned.
16
+ *
17
+ * The person's own session file is not read, moved, or changed by any of this.
18
+ */
19
+ import * as fs from "node:fs";
20
+ import * as path from "node:path";
21
+ export const MACHINE_CREDS_FILE_ENV = "HQ_MACHINE_CREDS_FILE";
22
+ export const MACHINE_TOKEN_STATE_DIR_ENV = "HQ_MACHINE_TOKEN_STATE_DIR";
23
+ /**
24
+ * Point the token layer at `paths` unless the caller already pinned it.
25
+ * Returns the variables it set, for tests and for `--verbose` output.
26
+ */
27
+ export function adoptAgentIdentityEnv(paths, env = process.env) {
28
+ const applied = {};
29
+ if (env[MACHINE_CREDS_FILE_ENV]?.trim())
30
+ return applied;
31
+ // Only adopt a tree that actually holds an identity: on a host that has not
32
+ // enrolled yet, leaving the environment alone keeps today's error messages.
33
+ try {
34
+ if (!fs.statSync(paths.machineCredsPath).isFile())
35
+ return applied;
36
+ }
37
+ catch {
38
+ return applied;
39
+ }
40
+ applied[MACHINE_CREDS_FILE_ENV] = paths.machineCredsPath;
41
+ env[MACHINE_CREDS_FILE_ENV] = paths.machineCredsPath;
42
+ // Per-agent token cache. Two identities on one computer must never share a
43
+ // minted-token file; the agent's belongs inside the agent's own tree.
44
+ if (!env[MACHINE_TOKEN_STATE_DIR_ENV]?.trim()) {
45
+ const dir = path.join(paths.agentDir, "token-state");
46
+ applied[MACHINE_TOKEN_STATE_DIR_ENV] = dir;
47
+ env[MACHINE_TOKEN_STATE_DIR_ENV] = dir;
48
+ }
49
+ return applied;
50
+ }
51
+ //# sourceMappingURL=adopt-identity.js.map
@@ -13,6 +13,14 @@
13
13
  * `HQ_AGENT_DIR` relocates the whole tree (tests, containers). The creds file
14
14
  * additionally honours `HQ_MACHINE_CREDS_FILE`, the override hq-cloud reads,
15
15
  * so a kit pointed at a custom creds path and hq-cloud's mint agree.
16
+ *
17
+ * LOCAL BOTS. A bot that runs on its owner's own computer cannot use the
18
+ * default tree: that machine already has the owner's HQ session, and an agent
19
+ * identity must not share a host account with a person's login. Such a bot
20
+ * enrolls into a SIBLING directory instead — `~/.hq-agent/<name>/` with the
21
+ * same layout — and every `hq agent …` command finds it here, so the bot does
22
+ * not have to carry environment variables around to be itself. Two or more
23
+ * local agents are ambiguous on purpose: pick one with `HQ_AGENT_DIR`.
16
24
  */
17
25
  export declare const AGENT_DIR_ENV = "HQ_AGENT_DIR";
18
26
  export declare const HOST_KEY_NAME = "host-key";
@@ -35,6 +43,18 @@ export interface AgentKitPaths {
35
43
  skillsDir: string;
36
44
  lastHeartbeatPath: string;
37
45
  }
46
+ /** Default name for a bot enrolled alongside its owner's own session. */
47
+ export declare const DEFAULT_LOCAL_AGENT_NAME = "local";
48
+ /** `~/.hq-agent` — the root, whether or not it holds an identity itself. */
49
+ export declare function agentRootDir(home?: string): string;
50
+ /** `~/.hq-agent/<name>` — an isolated home for one local bot. */
51
+ export declare function localAgentDir(name: string, home?: string): string;
52
+ /**
53
+ * Local agent homes under `~/.hq-agent`, by name, oldest name order. A
54
+ * directory counts only once it holds a creds file, so a half-written tree is
55
+ * never mistaken for an identity.
56
+ */
57
+ export declare function listLocalAgentDirs(home?: string): string[];
38
58
  export declare function agentDir(home?: string, env?: NodeJS.ProcessEnv): string;
39
59
  export declare function agentKitPaths(home?: string, env?: NodeJS.ProcessEnv): AgentKitPaths;
40
60
  export declare function componentStatePath(paths: Pick<AgentKitPaths, "stateDir">, component: KitComponent): string;
@@ -13,7 +13,16 @@
13
13
  * `HQ_AGENT_DIR` relocates the whole tree (tests, containers). The creds file
14
14
  * additionally honours `HQ_MACHINE_CREDS_FILE`, the override hq-cloud reads,
15
15
  * so a kit pointed at a custom creds path and hq-cloud's mint agree.
16
+ *
17
+ * LOCAL BOTS. A bot that runs on its owner's own computer cannot use the
18
+ * default tree: that machine already has the owner's HQ session, and an agent
19
+ * identity must not share a host account with a person's login. Such a bot
20
+ * enrolls into a SIBLING directory instead — `~/.hq-agent/<name>/` with the
21
+ * same layout — and every `hq agent …` command finds it here, so the bot does
22
+ * not have to carry environment variables around to be itself. Two or more
23
+ * local agents are ambiguous on purpose: pick one with `HQ_AGENT_DIR`.
16
24
  */
25
+ import * as fs from "node:fs";
17
26
  import * as os from "node:os";
18
27
  import * as path from "node:path";
19
28
  export const AGENT_DIR_ENV = "HQ_AGENT_DIR";
@@ -23,11 +32,62 @@ export const MACHINE_CREDS_NAME = "machine-creds.json";
23
32
  export const KIT_CONFIG_NAME = "kit.json";
24
33
  export const LAST_HEARTBEAT_NAME = "last-heartbeat.json";
25
34
  export const KIT_COMPONENTS = ["sync", "inbox"];
35
+ /** Default name for a bot enrolled alongside its owner's own session. */
36
+ export const DEFAULT_LOCAL_AGENT_NAME = "local";
37
+ /** `~/.hq-agent` — the root, whether or not it holds an identity itself. */
38
+ export function agentRootDir(home = os.homedir()) {
39
+ return path.join(home, ".hq-agent");
40
+ }
41
+ /** `~/.hq-agent/<name>` — an isolated home for one local bot. */
42
+ export function localAgentDir(name, home = os.homedir()) {
43
+ return path.join(agentRootDir(home), name);
44
+ }
45
+ /**
46
+ * Local agent homes under `~/.hq-agent`, by name, oldest name order. A
47
+ * directory counts only once it holds a creds file, so a half-written tree is
48
+ * never mistaken for an identity.
49
+ */
50
+ export function listLocalAgentDirs(home = os.homedir()) {
51
+ const root = agentRootDir(home);
52
+ let entries;
53
+ try {
54
+ entries = fs.readdirSync(root, { withFileTypes: true });
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ return entries
60
+ .filter((e) => e.isDirectory())
61
+ .map((e) => path.join(root, e.name))
62
+ .filter((dir) => {
63
+ try {
64
+ return fs.statSync(path.join(dir, MACHINE_CREDS_NAME)).isFile();
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ })
70
+ .sort();
71
+ }
26
72
  export function agentDir(home = os.homedir(), env = process.env) {
27
73
  const override = env[AGENT_DIR_ENV]?.trim();
28
74
  if (override)
29
75
  return override;
30
- return path.join(home, ".hq-agent");
76
+ const root = agentRootDir(home);
77
+ // An identity in the root wins: that is the dedicated-host layout, and a
78
+ // host that has one is not running a local bot beside a person.
79
+ try {
80
+ if (fs.statSync(path.join(root, MACHINE_CREDS_NAME)).isFile())
81
+ return root;
82
+ }
83
+ catch {
84
+ /* no identity in the root — fall through to the local homes */
85
+ }
86
+ const locals = listLocalAgentDirs(home);
87
+ // Exactly one is unambiguous. Two or more cannot be guessed, so keep the
88
+ // root and let the caller fail with a message naming HQ_AGENT_DIR rather
89
+ // than silently acting as whichever agent sorted first.
90
+ return locals.length === 1 ? locals[0] : root;
31
91
  }
32
92
  export function agentKitPaths(home = os.homedir(), env = process.env) {
33
93
  const dir = agentDir(home, env);
@@ -178,7 +178,7 @@ function formatMigrationCapabilityCheck(cap, root) {
178
178
  status: "WARN",
179
179
  checkId: "work-context.migration-capability",
180
180
  target,
181
- message: `Migration capability: offline at ${cap.checkedAt}; default-company stays locked.`,
181
+ message: `Migration capability: offline at ${cap.checkedAt}; selected-company membership is still verified when setting a default.`,
182
182
  };
183
183
  }
184
184
  const falseCount = cap.companies.filter((c) => !c.migration).length;
@@ -73,8 +73,9 @@ export interface MigrationCapabilityProbe {
73
73
  companies: MigrationCapabilityCompanyResult[];
74
74
  }
75
75
  /**
76
- * Unlock rule for default-company mode (US-017B): migration must be true for
77
- * EVERY company the caller belongs to. Offline / empty memberships locked.
76
+ * Diagnostic probe for migration capability across active memberships. The
77
+ * result is recorded and warned on, but does not gate a verified member from
78
+ * choosing a device-local default company.
78
79
  */
79
80
  export declare function probeMigrationCapabilityForMemberships(token: string, opts?: {
80
81
  listCompanies?: (token: string) => Promise<MeshCompany[]>;