@indigoai-us/hq-cli 5.131.0 → 5.133.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/dist/command-catalog.generated.d.ts +12 -0
  3. package/dist/command-catalog.generated.js +16 -0
  4. package/dist/commands/lanes.d.ts +15 -4
  5. package/dist/commands/lanes.js +478 -48
  6. package/dist/commands/mesh.js +24 -2
  7. package/dist/commands/update.js +1 -1
  8. package/dist/lib/bot/paths.d.ts +7 -5
  9. package/dist/lib/bot/paths.js +8 -1
  10. package/dist/lib/bot/prompt.d.ts +0 -2
  11. package/dist/lib/bot/prompt.js +5 -11
  12. package/dist/lib/bot/room-policy.d.ts +1 -24
  13. package/dist/lib/bot/room-policy.js +2 -35
  14. package/dist/lib/bot/run.d.ts +7 -8
  15. package/dist/lib/bot/run.js +21 -33
  16. package/dist/lib/bot/session.d.ts +1 -1
  17. package/dist/lib/bot/session.js +1 -1
  18. package/dist/lib/lanes/accounts.d.ts +6 -0
  19. package/dist/lib/lanes/accounts.js +2 -1
  20. package/dist/lib/lanes/config.d.ts +4 -0
  21. package/dist/lib/lanes/config.js +9 -0
  22. package/dist/lib/lanes/constants.d.ts +3 -0
  23. package/dist/lib/lanes/constants.js +3 -0
  24. package/dist/lib/lanes/index.d.ts +11 -3
  25. package/dist/lib/lanes/index.js +6 -1
  26. package/dist/lib/lanes/mesh-events.js +4 -0
  27. package/dist/lib/lanes/models.d.ts +78 -0
  28. package/dist/lib/lanes/models.js +205 -0
  29. package/dist/lib/lanes/move-account.d.ts +56 -0
  30. package/dist/lib/lanes/move-account.js +193 -0
  31. package/dist/lib/lanes/provider-rejection.d.ts +86 -0
  32. package/dist/lib/lanes/provider-rejection.js +258 -0
  33. package/dist/lib/lanes/quota.d.ts +23 -3
  34. package/dist/lib/lanes/quota.js +88 -14
  35. package/dist/lib/lanes/reconcile-install.d.ts +24 -3
  36. package/dist/lib/lanes/reconcile-install.js +48 -12
  37. package/dist/lib/lanes/reconcile.d.ts +5 -2
  38. package/dist/lib/lanes/reconcile.js +135 -13
  39. package/dist/lib/lanes/spawn.js +26 -5
  40. package/dist/lib/lanes/store.js +10 -0
  41. package/dist/lib/lanes/types.d.ts +51 -2
  42. package/dist/lib/lanes/types.js +5 -3
  43. package/dist/lib/lanes/watcher.d.ts +27 -0
  44. package/dist/lib/lanes/watcher.js +68 -5
  45. package/dist/lib/mesh/live/backfill-held.d.ts +4 -3
  46. package/dist/lib/mesh/live/backfill-held.js +37 -14
  47. package/dist/lib/mesh/live/daemon/doctor.d.ts +6 -0
  48. package/dist/lib/mesh/live/daemon/doctor.js +16 -2
  49. package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
  50. package/dist/lib/mesh/live/daemon/install.js +100 -7
  51. package/dist/lib/mesh/live/daemon/legacy-listen.d.ts +43 -0
  52. package/dist/lib/mesh/live/daemon/legacy-listen.js +89 -0
  53. package/dist/lib/mesh/live/daemon/live-cache.d.ts +26 -0
  54. package/dist/lib/mesh/live/daemon/live-cache.js +116 -0
  55. package/dist/lib/mesh/live/daemon/presence.d.ts +7 -3
  56. package/dist/lib/mesh/live/daemon/presence.js +10 -9
  57. package/dist/lib/mesh/live/daemon/run.d.ts +18 -0
  58. package/dist/lib/mesh/live/daemon/run.js +65 -6
  59. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +31 -0
  60. package/dist/lib/mesh/live/daemon/work-subscribe.js +124 -0
  61. package/dist/lib/mesh/live/emit-evidence.d.ts +19 -0
  62. package/dist/lib/mesh/live/emit-evidence.js +20 -2
  63. package/dist/lib/work-context/company.d.ts +16 -0
  64. package/dist/lib/work-context/company.js +46 -14
  65. package/dist/utils/self-update.js +4 -1
  66. package/package.json +2 -2
@@ -34,7 +34,11 @@ export declare const CONNECT_STABLE_GRACE_MS = 5000;
34
34
  * waits at least this long regardless of the backoff/grace-reset state.
35
35
  */
36
36
  export declare const RECONNECT_MIN_DELAY_MS = 1000;
37
- /** Always emit the first N close info lines, then at most one per interval. */
37
+ /**
38
+ * Close/reconnect info is one line per transition into reconnecting.
39
+ * Repeated `close` events while already reconnecting do not log again.
40
+ * Kept so older imports still resolve; they no longer gate the log.
41
+ */
38
42
  export declare const CLOSE_LOG_ALWAYS_COUNT = 3;
39
43
  export declare const CLOSE_LOG_INTERVAL_MS = 60000;
40
44
  export interface PresencePayload {
@@ -111,8 +115,8 @@ export declare class PresenceClient {
111
115
  private reconnectHandle;
112
116
  private stableHandle;
113
117
  private connectedAtMs;
114
- private closeLogCount;
115
- private lastCloseLogAtMs;
118
+ /** One close log per transition into reconnecting, until the next connect. */
119
+ private reconnectLogged;
116
120
  private stopped;
117
121
  private generation;
118
122
  private publishedTopics;
@@ -34,7 +34,11 @@ export const CONNECT_STABLE_GRACE_MS = 5_000;
34
34
  * waits at least this long regardless of the backoff/grace-reset state.
35
35
  */
36
36
  export const RECONNECT_MIN_DELAY_MS = 1_000;
37
- /** Always emit the first N close info lines, then at most one per interval. */
37
+ /**
38
+ * Close/reconnect info is one line per transition into reconnecting.
39
+ * Repeated `close` events while already reconnecting do not log again.
40
+ * Kept so older imports still resolve; they no longer gate the log.
41
+ */
38
42
  export const CLOSE_LOG_ALWAYS_COUNT = 3;
39
43
  export const CLOSE_LOG_INTERVAL_MS = 60_000;
40
44
  /**
@@ -81,8 +85,8 @@ export class PresenceClient {
81
85
  reconnectHandle = null;
82
86
  stableHandle = null;
83
87
  connectedAtMs = null;
84
- closeLogCount = 0;
85
- lastCloseLogAtMs = Number.NEGATIVE_INFINITY;
88
+ /** One close log per transition into reconnecting, until the next connect. */
89
+ reconnectLogged = false;
86
90
  stopped = false;
87
91
  generation = 0;
88
92
  publishedTopics = [];
@@ -281,6 +285,7 @@ export class PresenceClient {
281
285
  this.attempt = 0;
282
286
  this.lastBackoffMs = 0;
283
287
  }, CONNECT_STABLE_GRACE_MS);
288
+ this.reconnectLogged = false;
284
289
  this.setState("connected");
285
290
  void this.publishAll("online").then(() => {
286
291
  this.options.onOnline?.(bundle.companies.map((c) => c.companyUid));
@@ -383,13 +388,9 @@ export class PresenceClient {
383
388
  return delay;
384
389
  }
385
390
  maybeLogClose(elapsedMs, delayMs) {
386
- const now = this.timers.now();
387
- const withinAlways = this.closeLogCount < CLOSE_LOG_ALWAYS_COUNT;
388
- const intervalElapsed = now - this.lastCloseLogAtMs >= CLOSE_LOG_INTERVAL_MS;
389
- if (!withinAlways && !intervalElapsed)
391
+ if (this.reconnectLogged)
390
392
  return;
391
- this.closeLogCount += 1;
392
- this.lastCloseLogAtMs = now;
393
+ this.reconnectLogged = true;
393
394
  this.options.onInfo?.(`presence mqtt closed after ${elapsedMs}ms; reconnect in ${delayMs}ms`);
394
395
  }
395
396
  clearReconnect() {
@@ -18,6 +18,7 @@ import { type PidLockDeps } from "./pid-lock.js";
18
18
  import { PresenceClient, type MqttConnectFn } from "./presence.js";
19
19
  import { TranscriptWatcher, type TranscriptFs } from "./transcript-watch.js";
20
20
  import { type MeshEmitMode } from "./mode.js";
21
+ import { type CommandRunner, type LegacyListenPlatform } from "./legacy-listen.js";
21
22
  export declare const SPOOL_DEBOUNCE_MS = 2000;
22
23
  export declare const FLUSH_INTERVAL_MS = 10000;
23
24
  /** Keep startup recovery bounded: the next daemon start continues the backlog. */
@@ -90,6 +91,11 @@ export interface DaemonRunDeps {
90
91
  resolveMembership?: (token: string, slug: string, signal?: AbortSignal) => Promise<{
91
92
  companyUid: string;
92
93
  } | null>;
94
+ /**
95
+ * Machine credential (fleet box or local bot). A refused or empty membership
96
+ * probe defers; it does not clear the company. Defaults to isMachineIdentity().
97
+ */
98
+ machineIdentity?: boolean;
93
99
  /** Test seam immediately before a device-default revocation's guarded commit. */
94
100
  beforeDeviceDefaultRevocationCommit?: () => void;
95
101
  log?: (dir: string, line: string) => void;
@@ -97,10 +103,22 @@ export interface DaemonRunDeps {
97
103
  runForMs?: number;
98
104
  /** CLI version loaded by this daemon. Defaults to this package's version. */
99
105
  daemonVersion?: string;
106
+ /**
107
+ * Subscribe to the personal work topic and write live-cache.json.
108
+ * The `hq mesh daemon run` command turns this on. Tests leave it off
109
+ * unless they inject credentials.
110
+ */
111
+ enableWorkCache?: boolean;
112
+ fetchPersonalCredentials?: import("./credentials.js").PersonalCredentialsFetcher;
100
113
  /** Read the currently installed CLI version behind the launch entrypoint. */
101
114
  readInstalledCliVersion?: () => string | null;
102
115
  random?: () => number;
103
116
  now?: () => Date;
117
+ /** Host for legacy listen retirement. Tests inject this; default is process.platform. */
118
+ legacyListenPlatform?: LegacyListenPlatform;
119
+ /** Service-manager runner for legacy listen retirement (tests inject). */
120
+ legacyListenRun?: CommandRunner;
121
+ windowsLegacyTaskPresent?: boolean;
104
122
  }
105
123
  export interface DaemonHandle {
106
124
  stop: () => Promise<void>;
@@ -14,7 +14,7 @@ import * as path from "node:path";
14
14
  import { CLI_VERSION } from "../../../../cli-version.js";
15
15
  import { createWorkSessionDeliverer } from "../../client.js";
16
16
  import { listActiveMembershipCompanies, meshJson, resolveActiveMembershipCompany } from "../../api.js";
17
- import { ensureCognitoToken } from "../../../../utils/cognito-session.js";
17
+ import { ensureCognitoToken, isMachineIdentity } from "../../../../utils/cognito-session.js";
18
18
  import { replayOutbox } from "../../../work-context/outbox.js";
19
19
  import { workContextRoot } from "../../../work-context/paths.js";
20
20
  import { ensureDefaultCompany } from "../../../work-context/default-company.js";
@@ -26,17 +26,19 @@ import { backfillHeldSessions, } from "../backfill-held.js";
26
26
  import { reconcileObservation } from "../../../work-context/reconcile.js";
27
27
  import { workMeshRoot, workMeshSpoolPath } from "../paths.js";
28
28
  import { createVaultBoardReader, refreshBoundSessionBoards, BOARD_REFRESH_INTERVAL_MS, } from "./board-refresh.js";
29
- import { createContract3Fetcher, realTimerHost, } from "./credentials.js";
29
+ import { createContract3Fetcher, createPersonalRealtimeFetcher, realTimerHost, } from "./credentials.js";
30
30
  import { appendDaemonLog, daemonLogLine, ensureDaemonLog } from "./log.js";
31
31
  import { acquirePidLock, defaultPidLockDeps, releasePidLock, } from "./pid-lock.js";
32
32
  import { daemonDir } from "./paths.js";
33
- import { PresenceClient, } from "./presence.js";
33
+ import { PresenceClient, defaultMqttConnect, } from "./presence.js";
34
34
  import { defaultDaemonState, patchDaemonState, readDaemonState, writeDaemonState, } from "./state.js";
35
35
  import { workMeshHeldPath } from "../paths.js";
36
36
  import { noteHookSessionsFromSpoolFile, TRANSCRIPT_WATCH_INTERVAL_MS, TranscriptWatcher, } from "./transcript-watch.js";
37
37
  import { resolveMeshEmitMode, runsEmitPath, } from "./mode.js";
38
38
  import { hasCliVersionDrift, readInstalledCliVersion } from "./version-drift.js";
39
39
  import { readDaemonConfig } from "./config.js";
40
+ import { retireLegacyListen } from "./legacy-listen.js";
41
+ import { startWorkCacheSubscriber } from "./work-subscribe.js";
40
42
  export const SPOOL_DEBOUNCE_MS = 2_000;
41
43
  export const FLUSH_INTERVAL_MS = 10_000;
42
44
  /** Keep startup recovery bounded: the next daemon start continues the backlog. */
@@ -112,6 +114,15 @@ export async function runMeshDaemon(deps = {}) {
112
114
  const daemonVersion = deps.daemonVersion ?? CLI_VERSION;
113
115
  const installedCliVersion = deps.readInstalledCliVersion ?? readInstalledCliVersion;
114
116
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
117
+ const hostPlatform = process.platform === "darwin" || process.platform === "linux" || process.platform === "win32"
118
+ ? process.platform
119
+ : "other";
120
+ const retiredListen = retireLegacyListen({
121
+ home,
122
+ platform: deps.legacyListenPlatform ?? hostPlatform,
123
+ run: deps.legacyListenRun,
124
+ windowsTaskPresent: deps.windowsLegacyTaskPresent,
125
+ });
115
126
  const configuredRoots = readDaemonConfig(dir).trustedHqRoots;
116
127
  const trustedRoots = [...new Set([
117
128
  ...(deps.hqRoot ? [deps.hqRoot] : []),
@@ -119,6 +130,9 @@ export async function runMeshDaemon(deps = {}) {
119
130
  ...configuredRoots,
120
131
  ])];
121
132
  ensureDaemonLog(dir);
133
+ if (retiredListen.removed) {
134
+ log(dir, "retired legacy listener ai.getindigo.hq-work-mesh-listen");
135
+ }
122
136
  const lockDeps = deps.pidLock ?? defaultPidLockDeps({ isProcessAlive: alive });
123
137
  const lock = acquirePidLock(dir, lockDeps);
124
138
  if (!lock.ok) {
@@ -143,6 +157,7 @@ export async function runMeshDaemon(deps = {}) {
143
157
  const defaultGetToken = async () => ensureCognitoToken({ interactive: false, tokenSource: "machine" });
144
158
  const getToken = deps.getToken ?? defaultGetToken;
145
159
  const resolveMembership = deps.resolveMembership ?? resolveActiveMembershipCompany;
160
+ const machineIdentity = deps.machineIdentity ?? isMachineIdentity();
146
161
  /** The sole authorization seam for daemon tenant I/O derived from a device default. */
147
162
  const tenantGate = {
148
163
  authorize: async (input) => {
@@ -161,9 +176,18 @@ export async function runMeshDaemon(deps = {}) {
161
176
  authorization = await Promise.race([
162
177
  (async () => {
163
178
  const membership = await resolveMembership(await getToken(), slug, controller.signal);
164
- return membership
165
- ? { status: "authorized", uid: membership.companyUid }
166
- : { status: "denied" };
179
+ if (membership)
180
+ return { status: "authorized", uid: membership.companyUid };
181
+ // A machine token often cannot read /membership/me (401/403, or an
182
+ // empty person-membership list). That is "cannot tell", not "not
183
+ // a member". Leave the company in place and retry.
184
+ if (machineIdentity) {
185
+ return {
186
+ status: "unavailable",
187
+ error: "membership probe on a machine token did not confirm a company; company left unchanged",
188
+ };
189
+ }
190
+ return { status: "denied" };
167
191
  })(),
168
192
  new Promise((resolve) => {
169
193
  controller.signal.addEventListener("abort", () => resolve({
@@ -359,6 +383,7 @@ export async function runMeshDaemon(deps = {}) {
359
383
  });
360
384
  });
361
385
  let presence = null;
386
+ let workCache = null;
362
387
  if (deps.enablePresence !== false) {
363
388
  const fetchCredentialsRaw = deps.fetchCredentials ??
364
389
  (async () => {
@@ -420,6 +445,31 @@ export async function runMeshDaemon(deps = {}) {
420
445
  // Continue draining spool even if MQTT is down.
421
446
  }
422
447
  }
448
+ if (deps.enableWorkCache) {
449
+ try {
450
+ workCache = await startWorkCacheSubscriber({
451
+ meshRoot,
452
+ fetchCredentials: deps.fetchPersonalCredentials ?? (async () => {
453
+ const t = await getToken();
454
+ return createPersonalRealtimeFetcher({
455
+ token: t,
456
+ baseUrl: resolveVaultApiBase(env),
457
+ })();
458
+ }),
459
+ mqttConnect: (url, options) => {
460
+ const connect = deps.mqttConnect ?? defaultMqttConnect;
461
+ return connect(url, options);
462
+ },
463
+ now,
464
+ onInfo: (message) => log(dir, message.slice(0, 200)),
465
+ random: deps.random,
466
+ });
467
+ }
468
+ catch (err) {
469
+ const msg = err instanceof Error ? err.message : String(err);
470
+ log(dir, `work cache subscribe failed: ${msg.slice(0, 200)}`);
471
+ }
472
+ }
423
473
  let stopped = false;
424
474
  let restartRequested = false;
425
475
  let flushInFlight = null;
@@ -698,6 +748,15 @@ export async function runMeshDaemon(deps = {}) {
698
748
  // Final flush, then clean MQTT DISCONNECT so the broker emits the
699
749
  // lifecycle event; offline is published server-side only.
700
750
  await doFlushCycle("shutdown", { force: true });
751
+ if (workCache) {
752
+ try {
753
+ await workCache.stop();
754
+ }
755
+ catch (err) {
756
+ const msg = err instanceof Error ? err.message : String(err);
757
+ log(dir, `work cache stop error: ${msg.slice(0, 120)}`);
758
+ }
759
+ }
701
760
  if (presence) {
702
761
  try {
703
762
  await presence.stop();
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Contract-2 subscriber for the personal work topic.
3
+ * Owns live-cache.json writes. Presence publish stays on PresenceClient.
4
+ */
5
+ import type { IClientOptions } from "mqtt";
6
+ import { type PersonalCredentialsFetcher } from "./credentials.js";
7
+ export interface WorkCacheMqtt {
8
+ on(event: string, handler: (...args: any[]) => void): unknown;
9
+ subscribe(topic: string, opts: {
10
+ qos: 0 | 1;
11
+ }, cb?: (err?: Error | null) => void): unknown;
12
+ end(force?: boolean, cb?: () => void): unknown;
13
+ }
14
+ export interface WorkCacheSubscriber {
15
+ stop: () => Promise<void>;
16
+ }
17
+ export declare function startWorkCacheSubscriber(opts: {
18
+ meshRoot: string;
19
+ fetchCredentials?: PersonalCredentialsFetcher;
20
+ token?: () => Promise<string>;
21
+ baseUrl?: string;
22
+ mqttConnect: (url: string, options: IClientOptions) => WorkCacheMqtt;
23
+ now?: () => Date;
24
+ timers?: {
25
+ setTimeout: (fn: () => void, ms: number) => unknown;
26
+ clearTimeout: (handle: unknown) => void;
27
+ };
28
+ onInfo?: (message: string) => void;
29
+ random?: () => number;
30
+ }): Promise<WorkCacheSubscriber>;
31
+ //# sourceMappingURL=work-subscribe.d.ts.map
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Contract-2 subscriber for the personal work topic.
3
+ * Owns live-cache.json writes. Presence publish stays on PresenceClient.
4
+ */
5
+ import { createPersonalRealtimeFetcher, } from "./credentials.js";
6
+ import { presignIotWssUrl } from "./presign.js";
7
+ import { applyProjectViewPush, parseProjectViewPush } from "./live-cache.js";
8
+ import { backoffDelayMs, RECONNECT_MIN_DELAY_MS } from "./presence.js";
9
+ export async function startWorkCacheSubscriber(opts) {
10
+ const fetchCredentials = opts.fetchCredentials ??
11
+ (opts.token
12
+ ? createPersonalRealtimeFetcher({
13
+ token: await opts.token(),
14
+ baseUrl: opts.baseUrl,
15
+ })
16
+ : null);
17
+ if (!fetchCredentials) {
18
+ throw new Error("work cache subscriber needs credentials");
19
+ }
20
+ const now = opts.now ?? (() => new Date());
21
+ const timers = opts.timers ?? {
22
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
23
+ clearTimeout: (handle) => clearTimeout(handle),
24
+ };
25
+ const random = opts.random ?? Math.random;
26
+ let stopped = false;
27
+ let client = null;
28
+ let reconnectLogged = false;
29
+ let attempt = 0;
30
+ let timer = null;
31
+ let generation = 0;
32
+ const connect = async () => {
33
+ if (stopped)
34
+ return;
35
+ const gen = ++generation;
36
+ let bundle;
37
+ try {
38
+ bundle = await fetchCredentials();
39
+ }
40
+ catch (err) {
41
+ schedule(err);
42
+ return;
43
+ }
44
+ if (stopped || gen !== generation)
45
+ return;
46
+ const topic = bundle.topics.work;
47
+ if (!topic) {
48
+ opts.onInfo?.("work cache subscribe skipped: credentials have no work topic");
49
+ return;
50
+ }
51
+ const url = presignIotWssUrl(bundle.credentials, bundle.iotEndpoint, bundle.region, now());
52
+ const next = opts.mqttConnect(url, {
53
+ clientId: `${bundle.clientId}-work`,
54
+ clean: true,
55
+ reconnectPeriod: 0,
56
+ protocolVersion: 4,
57
+ });
58
+ client = next;
59
+ next.on("connect", () => {
60
+ if (stopped || gen !== generation)
61
+ return;
62
+ reconnectLogged = false;
63
+ attempt = 0;
64
+ next.subscribe(topic, { qos: 1 }, () => undefined);
65
+ });
66
+ next.on("message", (_topic, payload) => {
67
+ if (stopped || gen !== generation)
68
+ return;
69
+ if (_topic !== topic)
70
+ return;
71
+ const push = parseProjectViewPush(payload);
72
+ if (!push)
73
+ return;
74
+ const result = applyProjectViewPush(opts.meshRoot, push, now().toISOString());
75
+ if (result.applied) {
76
+ opts.onInfo?.(`project-view cache cursor=${result.cursor} ${push.companyUid}/${push.projectId}`);
77
+ }
78
+ });
79
+ next.on("close", () => {
80
+ if (stopped || gen !== generation)
81
+ return;
82
+ schedule();
83
+ });
84
+ };
85
+ const schedule = (err) => {
86
+ if (stopped)
87
+ return;
88
+ if (!reconnectLogged) {
89
+ reconnectLogged = true;
90
+ const detail = err instanceof Error ? `: ${err.message}` : "";
91
+ opts.onInfo?.(`work cache mqtt reconnecting${detail}`.slice(0, 200));
92
+ }
93
+ if (timer !== null)
94
+ timers.clearTimeout(timer);
95
+ const delay = backoffDelayMs(attempt, 1000, 60_000, random, 0, RECONNECT_MIN_DELAY_MS);
96
+ attempt += 1;
97
+ timer = timers.setTimeout(() => {
98
+ timer = null;
99
+ void connect();
100
+ }, delay);
101
+ };
102
+ await connect();
103
+ return {
104
+ stop: async () => {
105
+ stopped = true;
106
+ generation += 1;
107
+ if (timer !== null)
108
+ timers.clearTimeout(timer);
109
+ const current = client;
110
+ client = null;
111
+ if (!current)
112
+ return;
113
+ await new Promise((resolve) => {
114
+ try {
115
+ current.end(true, () => resolve());
116
+ }
117
+ catch {
118
+ resolve();
119
+ }
120
+ });
121
+ },
122
+ };
123
+ }
124
+ //# sourceMappingURL=work-subscribe.js.map
@@ -37,6 +37,17 @@ export interface BuildEmitEvidenceInput {
37
37
  readMeta?: (sessionId: string, hqRoot: string | undefined) => string | undefined;
38
38
  /** Injectable work-tree finder (tests). */
39
39
  findRepo?: (startDir: string) => string | null;
40
+ /**
41
+ * Env for HQ_SPAWN_COMPANY and the identity file. Defaults to empty so a
42
+ * unit test does not inherit the developer shell. Callers that emit pass
43
+ * `process.env`.
44
+ */
45
+ env?: NodeJS.ProcessEnv;
46
+ /** Injectable machine-company reader (tests). */
47
+ readMachineCompany?: (env: NodeJS.ProcessEnv) => {
48
+ slug?: string;
49
+ uid?: string;
50
+ } | undefined;
40
51
  }
41
52
  /** Dedupe absolute-ish paths, preserving first-seen order. */
42
53
  export declare function normalizeTouchedPaths(paths: Array<string | undefined | null>): string[];
@@ -46,4 +57,12 @@ export declare function normalizeTouchedPaths(paths: Array<string | undefined |
46
57
  * first touched file). All fields are omitted when empty.
47
58
  */
48
59
  export declare function buildEmitEvidence(input: BuildEmitEvidenceInput): EmitEvidence;
60
+ /**
61
+ * A machine credential with no company must not be posted. The server would
62
+ * file it as company NONE. Hold it instead.
63
+ */
64
+ export declare function machineEmitDisposition(input: {
65
+ machine: boolean;
66
+ companySlug?: string;
67
+ }): "post" | "hold";
49
68
  //# sourceMappingURL=emit-evidence.d.ts.map
@@ -9,7 +9,7 @@
9
9
  * work-tree off disk. Never reads prompts/tokens; never hits the network.
10
10
  */
11
11
  import * as path from "node:path";
12
- import { readMetaCompanySlug } from "../../work-context/company.js";
12
+ import { readMachineCompanyBinding, readMetaCompanySlug } from "../../work-context/company.js";
13
13
  import { findWorkTreeRoot } from "../../work-context/repo-remote.js";
14
14
  function clean(v) {
15
15
  return typeof v === "string" && v.trim() ? v.trim() : undefined;
@@ -41,7 +41,16 @@ export function buildEmitEvidence(input) {
41
41
  const cwd = clean(input.cwd);
42
42
  const hqRoot = clean(input.hqRoot);
43
43
  const touchedPaths = normalizeTouchedPaths(input.touchedPaths ?? []);
44
- const companySlug = clean(input.companySlug) ?? clean(readMeta(input.sessionId, hqRoot));
44
+ const env = input.env ?? {};
45
+ const machine = (input.readMachineCompany ?? readMachineCompanyBinding)(env);
46
+ const spawned = clean(env.HQ_SPAWN_COMPANY);
47
+ // Explicit, spawn, session meta, then the box binding (slug, else uid).
48
+ // A uid with no slug still rides companySlug so the event is not company NONE.
49
+ const companySlug = clean(input.companySlug) ??
50
+ spawned ??
51
+ clean(readMeta(input.sessionId, hqRoot)) ??
52
+ (machine && machine.slug !== spawned ? clean(machine.slug) : undefined) ??
53
+ clean(machine?.uid);
45
54
  let repoPath = clean(input.repoPath);
46
55
  if (!repoPath) {
47
56
  // Search from the cwd (a directory) or the directory of the first touched
@@ -69,6 +78,15 @@ export function buildEmitEvidence(input) {
69
78
  evidence.task = task;
70
79
  return evidence;
71
80
  }
81
+ /**
82
+ * A machine credential with no company must not be posted. The server would
83
+ * file it as company NONE. Hold it instead.
84
+ */
85
+ export function machineEmitDisposition(input) {
86
+ if (input.machine && !clean(input.companySlug))
87
+ return "hold";
88
+ return "post";
89
+ }
72
90
  /** Directory of the first absolute touched file, for work-tree discovery. */
73
91
  function firstFileDir(touchedPaths) {
74
92
  const abs = touchedPaths.find((p) => path.isAbsolute(p));
@@ -77,7 +77,23 @@ export declare const DEFAULT_AGENT_IDENTITY_FILE = "/var/lib/hq-agent/identity.j
77
77
  * Missing/unreadable/malformed → undefined
78
78
  * (never throws; never logs file contents).
79
79
  */
80
+ export interface AgentIdentityCompany {
81
+ uid?: string;
82
+ slug?: string;
83
+ }
84
+ /** Company uid and slug from the fleet identity file. Missing file → undefined. */
85
+ export declare function readAgentIdentityCompany(env?: NodeJS.ProcessEnv): AgentIdentityCompany | undefined;
80
86
  export declare function readAgentIdentityCompanyUid(env?: NodeJS.ProcessEnv): string | undefined;
87
+ /**
88
+ * Company named for a machine session.
89
+ * Order: HQ_SPAWN_COMPANY, identity-file slug, identity-file uid.
90
+ * Session meta is not included; callers place it between spawn and the file.
91
+ * No company → undefined. Never invents NONE.
92
+ */
93
+ export declare function readMachineCompanyBinding(env?: NodeJS.ProcessEnv): {
94
+ slug?: string;
95
+ uid?: string;
96
+ } | undefined;
81
97
  /**
82
98
  * Agent boxes must never infer a human device default. The file itself is the
83
99
  * machine marker; legacy identity files do not necessarily contain a company
@@ -38,13 +38,7 @@ function normalizeSlug(value) {
38
38
  return undefined;
39
39
  return s;
40
40
  }
41
- /**
42
- * Read `companyUid` from the agent identity file when
43
- * `HQ_AGENT_IDENTITY_FILE` is set, or the conventional fleet path otherwise.
44
- * Missing/unreadable/malformed → undefined
45
- * (never throws; never logs file contents).
46
- */
47
- export function readAgentIdentityCompanyUid(env = process.env) {
41
+ function readAgentIdentityRecord(env) {
48
42
  // An explicit override (including empty/missing paths) never falls back to
49
43
  // another identity. This prevents accidental attribution to a different box.
50
44
  const file = (env[HQ_AGENT_IDENTITY_FILE_ENV] ?? DEFAULT_AGENT_IDENTITY_FILE).trim();
@@ -54,15 +48,49 @@ export function readAgentIdentityCompanyUid(env = process.env) {
54
48
  if (!fs.existsSync(file))
55
49
  return undefined;
56
50
  const raw = JSON.parse(fs.readFileSync(file, "utf8"));
57
- const uid = raw.companyUid;
58
- return typeof uid === "string" && uid.trim().length > 0
59
- ? uid.trim()
60
- : undefined;
51
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
52
+ return undefined;
53
+ return raw;
61
54
  }
62
55
  catch {
63
56
  return undefined;
64
57
  }
65
58
  }
59
+ /** Company uid and slug from the fleet identity file. Missing file → undefined. */
60
+ export function readAgentIdentityCompany(env = process.env) {
61
+ const raw = readAgentIdentityRecord(env);
62
+ if (!raw)
63
+ return undefined;
64
+ const uid = typeof raw.companyUid === "string" ? raw.companyUid.trim() : "";
65
+ const slug = normalizeSlug(typeof raw.companySlug === "string" ? raw.companySlug : undefined);
66
+ if (!uid && !slug)
67
+ return undefined;
68
+ return {
69
+ ...(uid ? { uid } : {}),
70
+ ...(slug ? { slug } : {}),
71
+ };
72
+ }
73
+ export function readAgentIdentityCompanyUid(env = process.env) {
74
+ return readAgentIdentityCompany(env)?.uid;
75
+ }
76
+ /**
77
+ * Company named for a machine session.
78
+ * Order: HQ_SPAWN_COMPANY, identity-file slug, identity-file uid.
79
+ * Session meta is not included; callers place it between spawn and the file.
80
+ * No company → undefined. Never invents NONE.
81
+ */
82
+ export function readMachineCompanyBinding(env = process.env) {
83
+ const spawn = normalizeSlug(env.HQ_SPAWN_COMPANY);
84
+ const identity = readAgentIdentityCompany(env);
85
+ if (spawn)
86
+ return { slug: spawn, ...(identity?.uid ? { uid: identity.uid } : {}) };
87
+ if (!identity?.slug && !identity?.uid)
88
+ return undefined;
89
+ return {
90
+ ...(identity?.slug ? { slug: identity.slug } : {}),
91
+ ...(identity?.uid ? { uid: identity.uid } : {}),
92
+ };
93
+ }
66
94
  /**
67
95
  * Agent boxes must never infer a human device default. The file itself is the
68
96
  * machine marker; legacy identity files do not necessarily contain a company
@@ -273,11 +301,15 @@ export function resolveCompany(input) {
273
301
  // 4. Agent-box identity file (HQ_AGENT_IDENTITY_FILE). Prefer as the
274
302
  // device-default role with source trusted_explicit — never device_default /
275
303
  // "default company mode". Below HQ_SPAWN_COMPANY; above device default.
276
- const identityUid = readAgentIdentityCompanyUid(env);
277
- if (identityUid) {
304
+ const identity = readAgentIdentityCompany(env);
305
+ if (identity?.uid || identity?.slug) {
278
306
  return {
279
307
  status: "resolved",
280
- company: { uid: identityUid, source: "trusted_explicit" },
308
+ company: {
309
+ ...(identity.slug ? { slug: identity.slug } : {}),
310
+ ...(identity.uid ? { uid: identity.uid } : {}),
311
+ source: "trusted_explicit",
312
+ },
281
313
  };
282
314
  }
283
315
  // 4b. Persisted per-repo company map (gap 4). A person answered once for this
@@ -282,7 +282,10 @@ async function updateAndReexec(argv, flavor, known, deps) {
282
282
  // A resident mesh daemon keeps the old JS loaded after a global install.
283
283
  // Service restart is best-effort and cannot turn a successful update red.
284
284
  try {
285
- const restart = (deps.restartDaemon ?? restartDaemonServiceIfInstalled)();
285
+ const restart = (deps.restartDaemon ?? (() => restartDaemonServiceIfInstalled({
286
+ env,
287
+ hqRoot: env.HQ_ROOT,
288
+ })))();
286
289
  if (restart?.message)
287
290
  console.error(chalk.yellow(` ${restart.message}`));
288
291
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.131.0",
3
+ "version": "5.133.0",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
41
41
  "@aws-sdk/client-s3": "^3.1049.0",
42
- "@indigoai-us/hq-cloud": "~6.16.60",
42
+ "@indigoai-us/hq-cloud": "~6.16.74",
43
43
  "@indigoai-us/hq-flags-client": "^0.1.2",
44
44
  "@sentry/node": "^10.49.0",
45
45
  "@tobilu/qmd": "2.5.3",