@indigoai-us/hq-cli 5.345.133 → 5.345.135

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 (44) hide show
  1. package/CHANGELOG.md +17 -3
  2. package/dist/command-catalog.generated.d.ts +45 -0
  3. package/dist/command-catalog.generated.js +56 -0
  4. package/dist/command-registration-plan.d.ts +6 -0
  5. package/dist/command-registration-plan.js +1 -0
  6. package/dist/commands/access.d.ts +2 -0
  7. package/dist/commands/access.js +58 -9
  8. package/dist/commands/core-bucket-c-handoff-knowledge-native.d.ts +3 -1
  9. package/dist/commands/core-bucket-c-handoff-knowledge-native.js +8 -4
  10. package/dist/commands/core-handoff.d.ts +2 -0
  11. package/dist/commands/core-handoff.js +3 -1
  12. package/dist/commands/core-pipeline-workflow-runner-native.js +7 -5
  13. package/dist/commands/fingerprint.d.ts +10 -0
  14. package/dist/commands/fingerprint.js +191 -0
  15. package/dist/commands/lanes.js +9 -1
  16. package/dist/lib/fingerprint/fixtures.d.ts +19 -0
  17. package/dist/lib/fingerprint/fixtures.js +61 -0
  18. package/dist/lib/fingerprint/format.d.ts +50 -0
  19. package/dist/lib/fingerprint/format.js +105 -0
  20. package/dist/lib/fingerprint/offline.d.ts +16 -0
  21. package/dist/lib/fingerprint/offline.js +44 -0
  22. package/dist/lib/fingerprint/registry-client.d.ts +74 -0
  23. package/dist/lib/fingerprint/registry-client.js +141 -0
  24. package/dist/lib/fingerprint/resolve-local.d.ts +14 -0
  25. package/dist/lib/fingerprint/resolve-local.js +67 -0
  26. package/dist/lib/fingerprint/self-core.d.ts +139 -0
  27. package/dist/lib/fingerprint/self-core.js +371 -0
  28. package/dist/lib/fingerprint/self.d.ts +44 -0
  29. package/dist/lib/fingerprint/self.js +191 -0
  30. package/dist/lib/lanes/spawn.d.ts +19 -1
  31. package/dist/lib/lanes/spawn.js +50 -6
  32. package/dist/lib/lanes/story-ledger.d.ts +1 -0
  33. package/dist/lib/lanes/story-ledger.js +3 -1
  34. package/dist/lib/lanes/types.d.ts +12 -0
  35. package/dist/lib/lanes/types.js +12 -1
  36. package/dist/utils/cli-network-lifecycle.d.ts +2 -0
  37. package/dist/utils/cli-network-lifecycle.js +5 -1
  38. package/dist/utils/cognito-session.d.ts +2 -21
  39. package/dist/utils/cognito-session.js +3 -84
  40. package/dist/utils/person-token-cache.d.ts +32 -0
  41. package/dist/utils/person-token-cache.js +95 -0
  42. package/dist/utils/vault-api.d.ts +21 -1
  43. package/dist/utils/vault-api.js +54 -5
  44. package/package.json +1 -1
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Build the fingerprint for the current HQ session (`hq fingerprint self`).
3
+ *
4
+ * Sources, in order:
5
+ * - session id: the first valid value of HQ_SESSION_ID, CLAUDE_CODE_SESSION_ID,
6
+ * CLAUDE_SESSION_ID, CODEX_SESSION_ID, CODEX_THREAD_ID, GROK_SESSION_ID (same
7
+ * order as core/scripts/lib/session-id.sh). workspace/sessions/.current is
8
+ * never read: it names whichever session wrote last, not this one.
9
+ * - identity: `custom:entityUid` on the cached ID token, then the agent uid in
10
+ * the machine creds, then this session's fingerprint.yaml cache (only when
11
+ * it was written for the same token owner), then a person lookup (3s cap,
12
+ * skipped with --offline, without a session id, and for outpost and agent
13
+ * principals). A lookup is cached once it succeeds or the server says the
14
+ * caller has no person entity; transient failures retry next time.
15
+ * - machine: HQ_MACHINE_ID, then `<hqRoot>/.hq/machine-id`.
16
+ * - company: the session's meta.yaml company_slug, then HQ_COMPANY, then
17
+ * HQ_SPAWN_COMPANY. A value that is not a slug is replaced with `-`.
18
+ *
19
+ * The result is written to workspace/sessions/<sid>/fingerprint.yaml when a
20
+ * session id is known. registerSelfFingerprint (US-007) then records the
21
+ * fingerprint with hq-pro once per session and writes `registered_at`, or
22
+ * `registration_skipped` when hq-pro will never accept it (outpost hosts, a
23
+ * 403). It never prints and never throws; failures go to ~/.hq/fingerprint.log.
24
+ * registry-client is imported lazily, only on the registration path.
25
+ *
26
+ * The commit-msg hook runs `self --offline` on every commit, so this module
27
+ * keeps its static imports light: cognito-session, hq-cloud, and vault-api are
28
+ * loaded only on the paths that need them. The network-free core lives in
29
+ * self-core.ts so lane start and the handoff commits never reach them.
30
+ */
31
+ import * as fs from "node:fs";
32
+ import { finishFingerprint, IDENTITY_LOOKUP_TIMEOUT_MS, IDENTITY_UID_RE, identityContext, machineIdForFingerprint, readRegistrationState, recordRegistrationState, } from "./self-core.js";
33
+ export * from "./self-core.js";
34
+ async function defaultLoadIdToken() {
35
+ const { loadCachedTokens } = await import("../../utils/cognito-session.js");
36
+ return loadCachedTokens()?.idToken ?? null;
37
+ }
38
+ /** An unparseable creds file yields null, so it never marks the host as fleet. */
39
+ export async function defaultLoadMachineCreds() {
40
+ const { loadMachineCreds, machineCredsFilePath } = await import("@indigoai-us/hq-cloud");
41
+ if (!fs.existsSync(machineCredsFilePath()))
42
+ return null;
43
+ const creds = loadMachineCreds();
44
+ return creds ? { entityType: creds.entityType, entityUid: creds.entityUid } : null;
45
+ }
46
+ async function defaultResolveIdentity(idToken, signal) {
47
+ const { resolveCallerUid } = await import("../../utils/vault-api.js");
48
+ return resolveCallerUid(idToken, undefined, { signal });
49
+ }
50
+ /** Races the lookup against the timeout and aborts the request when time runs out. */
51
+ async function withTimeout(run, ms) {
52
+ const controller = new AbortController();
53
+ let timer;
54
+ const timeout = new Promise((_, reject) => {
55
+ timer = setTimeout(() => {
56
+ const err = new Error(`identity lookup timed out after ${ms}ms`);
57
+ controller.abort(err);
58
+ reject(err);
59
+ }, ms);
60
+ });
61
+ try {
62
+ return await Promise.race([run(controller.signal), timeout]);
63
+ }
64
+ finally {
65
+ clearTimeout(timer);
66
+ }
67
+ }
68
+ /** vault-api's NoPersonEntityError, matched by name so vault-api stays lazily loaded. */
69
+ function isNoPersonEntityError(err) {
70
+ return err instanceof Error && err.name === "NoPersonEntityError";
71
+ }
72
+ export async function buildSelfFingerprint(deps) {
73
+ const warn = deps.warn ?? ((message) => process.stderr.write(`${message}\n`));
74
+ let idToken = null;
75
+ try {
76
+ idToken = await (deps.loadIdToken ?? defaultLoadIdToken)();
77
+ }
78
+ catch (err) {
79
+ warn(`hq fingerprint: could not read the cached token: ${err.message}`);
80
+ }
81
+ let creds = null;
82
+ try {
83
+ creds = await (deps.loadMachineCreds ?? defaultLoadMachineCreds)();
84
+ }
85
+ catch (err) {
86
+ warn(`hq fingerprint: could not read machine creds: ${err.message}`);
87
+ }
88
+ const ctx = identityContext(deps, idToken, creds);
89
+ if (ctx.needsLookup && ctx.idToken) {
90
+ const resolveIdentity = deps.resolveIdentity ?? defaultResolveIdentity;
91
+ const token = ctx.idToken;
92
+ try {
93
+ const resolved = await withTimeout((signal) => resolveIdentity(token, signal), deps.lookupTimeoutMs ?? IDENTITY_LOOKUP_TIMEOUT_MS);
94
+ ctx.lookupDone = true;
95
+ if (IDENTITY_UID_RE.test(resolved.uid)) {
96
+ ctx.identityUid = resolved.uid;
97
+ ctx.source = "lookup";
98
+ }
99
+ }
100
+ catch (err) {
101
+ // "No person entity" is a definitive answer; anything else may be transient.
102
+ if (isNoPersonEntityError(err))
103
+ ctx.lookupDone = true;
104
+ warn(`hq fingerprint: identity lookup failed: ${err.message}`);
105
+ }
106
+ }
107
+ return finishFingerprint(deps, ctx);
108
+ }
109
+ /**
110
+ * Records this session's fingerprint with hq-pro (POST /v1/fingerprints), at
111
+ * most once per session. Fire-and-forget: it never writes to stdout or stderr
112
+ * and never throws, so the printed fingerprint and the exit code stay the
113
+ * same whatever the registry does.
114
+ *
115
+ * hq-pro denies company-outpost machine tokens, so outpost hosts skip the call
116
+ * and record `registration_skipped: outpost`. A 403 is also final and is
117
+ * recorded the same way. A 404 means the registry is not deployed for this
118
+ * API, so it records `registration_skipped: unavailable` and the session stops
119
+ * calling. Any other failure is logged and retried next run.
120
+ */
121
+ export async function registerSelfFingerprint(self, deps) {
122
+ const env = deps.env ?? process.env;
123
+ if (deps.register === false)
124
+ return { status: "skipped", reason: "--no-register" };
125
+ if (deps.offline || env.HQ_FINGERPRINT_OFFLINE === "1")
126
+ return { status: "skipped", reason: "offline" };
127
+ const { fingerprint, company, identityUid, sessionId, machineId, hostKind } = self;
128
+ if (!fingerprint || !company || company === "-" || !identityUid || !sessionId || !machineId) {
129
+ return { status: "skipped", reason: "incomplete fingerprint" };
130
+ }
131
+ // Cache read and write problems stay quiet here: the build already warned.
132
+ const quiet = () => { };
133
+ const state = readRegistrationState(deps.hqRoot, sessionId, fingerprint, quiet);
134
+ if (!state)
135
+ return { status: "skipped", reason: "no session cache" };
136
+ if (state.registeredAt)
137
+ return { status: "already-registered" };
138
+ if (state.skipped)
139
+ return { status: "skipped", reason: state.skipped };
140
+ try {
141
+ const registry = await import("./registry-client.js");
142
+ const log = (op, detail) => registry.appendFingerprintLog(op, detail, { dir: deps.logDir, now: deps.now });
143
+ if (hostKind === "outpost") {
144
+ log("register", `skipped outpost: hq-pro denies company-outpost machine tokens fingerprint=${fingerprint}`);
145
+ recordRegistrationState(deps.hqRoot, sessionId, fingerprint, { skipped: "outpost" }, quiet);
146
+ return { status: "skipped", reason: "outpost" };
147
+ }
148
+ let token = null;
149
+ try {
150
+ token = await (deps.loadIdToken ?? defaultLoadIdToken)();
151
+ }
152
+ catch (err) {
153
+ log("register", `failed: could not read the cached token: ${err.name}`);
154
+ return { status: "failed", reason: "token unreadable" };
155
+ }
156
+ if (!token)
157
+ return { status: "skipped", reason: "not signed in" };
158
+ const result = await registry.registerFingerprint({
159
+ fingerprint,
160
+ companySlug: company,
161
+ identityUid,
162
+ machineId: machineIdForFingerprint(machineId),
163
+ hostKind,
164
+ sessionId,
165
+ }, token, { fetch: deps.fetch, timeoutMs: deps.timeoutMs });
166
+ if (result.ok) {
167
+ const at = (deps.now ?? (() => new Date()))().toISOString();
168
+ recordRegistrationState(deps.hqRoot, sessionId, fingerprint, { registeredAt: at }, quiet);
169
+ return { status: "registered" };
170
+ }
171
+ const detail = registry.describeFailure(result);
172
+ if (result.status === 403) {
173
+ // A denial will not change on retry; stop calling for this fingerprint.
174
+ log("register", `skipped forbidden: ${detail} fingerprint=${fingerprint}`);
175
+ recordRegistrationState(deps.hqRoot, sessionId, fingerprint, { skipped: "forbidden" }, quiet);
176
+ return { status: "skipped", reason: "forbidden" };
177
+ }
178
+ if (result.status === 404) {
179
+ // The registry route is not deployed here; stop calling for this session.
180
+ log("register", `skipped unavailable: ${detail} fingerprint=${fingerprint}`);
181
+ recordRegistrationState(deps.hqRoot, sessionId, fingerprint, { skipped: "unavailable" }, quiet);
182
+ return { status: "skipped", reason: "unavailable" };
183
+ }
184
+ log("register", `failed: ${detail} fingerprint=${fingerprint}`);
185
+ return { status: "failed", reason: detail };
186
+ }
187
+ catch (err) {
188
+ return { status: "failed", reason: err.message };
189
+ }
190
+ }
191
+ //# sourceMappingURL=self.js.map
@@ -66,6 +66,8 @@ export type ProveDetachedFn = (pid: number) => DetachProof;
66
66
  export type KillProcessFn = (pid: number, signal?: NodeJS.Signals) => void;
67
67
  export interface SpawnLaneDeps {
68
68
  env?: NodeJS.ProcessEnv;
69
+ /** Builds the lane's fingerprint from the worker env. Defaults to laneStartFingerprint. */
70
+ buildFingerprint?: (hqRoot: string, workerEnv: NodeJS.ProcessEnv) => string | null;
69
71
  /** Test seam for the default-off quota account failover registry gate. */
70
72
  quotaAccountFailoverEnabled?: boolean;
71
73
  /** Return the selected retry transition without writing or starting a worker. */
@@ -255,7 +257,23 @@ export declare function buildProviderCommand(provider: LaneProvider, input: {
255
257
  export declare function workerEnv(base: NodeJS.ProcessEnv, resolved: {
256
258
  envVar: string;
257
259
  home: string;
258
- }, laneId: string, hqRoot: string, autoCompactTokens?: number, meshCompany?: LaneCompanyRef, workerPolicyDir?: string, registryWorker?: LaneRecord["registry_worker"], homedir?: () => string): NodeJS.ProcessEnv;
260
+ }, laneId: string, hqRoot: string, autoCompactTokens?: number, meshCompany?: LaneCompanyRef, workerPolicyDir?: string, registryWorker?: LaneRecord["registry_worker"], homedir?: () => string, session?: WorkerSessionEnv): NodeJS.ProcessEnv;
261
+ /** Session ids a worker must not inherit from the process that spawns it. */
262
+ export declare const WORKER_SESSION_ENV_VARS: readonly ["HQ_SESSION_ID", "CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID", "CODEX_SESSION_ID", "CODEX_THREAD_ID", "GROK_SESSION_ID"];
263
+ /**
264
+ * The worker's fingerprint, built offline from the worker env at lane start.
265
+ * Null when it cannot be built (no identity and no session id, or an error);
266
+ * the lane still starts.
267
+ */
268
+ export declare function laneStartFingerprint(hqRoot: string, env: NodeJS.ProcessEnv): string | null;
269
+ export interface WorkerSessionEnv {
270
+ /** The worker's own provider session id, when known before launch. */
271
+ workerSessionId?: string;
272
+ /** The senior's session id, when the senior is a session. */
273
+ parentSessionId?: string;
274
+ }
275
+ /** The session identity a lane turn exports to its worker (see `workerEnv`). */
276
+ export declare function laneWorkerSessionEnv(lane: Pick<LaneRecord, "senior">, workerSessionId: string | undefined): WorkerSessionEnv;
259
277
  export declare const WORKER_POLICY_HOOK_CAPABILITY = "# hq-capability: worker-policy-dir v1";
260
278
  /** Grok does not run HQ hooks; Claude and Codex use the installed capability marker. */
261
279
  export declare function workerPolicyDeliveryMode(hqRoot: string, provider: LaneProvider): "hook" | "prompt";
@@ -42,6 +42,8 @@ import { MESSAGE_REPLAY_STATE_NAME } from "./message-replay.js";
42
42
  import { markAnsweredQuestionsDelivered } from "./questions.js";
43
43
  import { hasEarlierEligibleQueuedLane, laneAdmissionCapReason, withLaneAdmissionLock, } from "./admission.js";
44
44
  import { resolveAdmissionHost } from "./admission-host.js";
45
+ import { offlineFingerprint } from "../fingerprint/offline.js";
46
+ import { SESSION_ID_ENV_VARS } from "../fingerprint/self-core.js";
45
47
  import { checkLaneDiskSpace, laneDiskAccount } from "./disk-guard.js";
46
48
  import { LANE_ENVELOPE_JSON_SCHEMA, ENVELOPE_INVALID_REASON, parseLaneProvider, } from "./types.js";
47
49
  export const LANES_RUNS_REL = path.join("workspace", "lanes-runs");
@@ -1661,7 +1663,7 @@ export function buildProviderCommand(provider, input) {
1661
1663
  args: appendFastModeFlags(parsed, appendEffortFlag(parsed, appendModelFlag(parsed, appendCodexWorkerPolicyDirFlag(parsed, appendProviderProfileFlags(parsed, args, input.profile), input.workerPolicyDir), input.model), input.effort), input.fastMode),
1662
1664
  };
1663
1665
  }
1664
- export function workerEnv(base, resolved, laneId, hqRoot, autoCompactTokens, meshCompany, workerPolicyDir, registryWorker, homedir) {
1666
+ export function workerEnv(base, resolved, laneId, hqRoot, autoCompactTokens, meshCompany, workerPolicyDir, registryWorker, homedir, session) {
1665
1667
  const accepted = Object.values(PROVIDER_HOME_ENV);
1666
1668
  if (!accepted.includes(resolved.envVar)) {
1667
1669
  throw new LanesError("unknown_provider_home_env", `Unknown provider home variable ${JSON.stringify(resolved.envVar)}. Accepted: ${accepted.join(", ")}`, { accepted, value: resolved.envVar });
@@ -1704,8 +1706,48 @@ export function workerEnv(base, resolved, laneId, hqRoot, autoCompactTokens, mes
1704
1706
  else {
1705
1707
  delete env.HQ_SPAWN_COMPANY;
1706
1708
  }
1709
+ if (meshCompany?.slug) {
1710
+ env.HQ_COMPANY = meshCompany.slug;
1711
+ }
1712
+ else {
1713
+ delete env.HQ_COMPANY;
1714
+ }
1715
+ // Session identity precedence for the worker (the fingerprint builder and
1716
+ // the commit-msg hook read HQ_SESSION_ID first, then the harness ids):
1717
+ // 1. HQ_SESSION_ID is the worker's own session, the id `--resume` would
1718
+ // use. It is set only when that id is known before launch (a resume, or
1719
+ // a remote-control lane whose id hq-cli mints). A cold first turn leaves
1720
+ // it unset rather than guessing.
1721
+ // 2. The harness ids (CLAUDE_CODE_SESSION_ID and the rest) are set by the
1722
+ // worker's own CLI once it starts. Values inherited from the senior are
1723
+ // dropped so a worker never reports the senior's session as its own.
1724
+ // HQ_PARENT_SESSION_ID names the senior session when the senior is one.
1725
+ for (const name of WORKER_SESSION_ENV_VARS)
1726
+ delete env[name];
1727
+ delete env.HQ_PARENT_SESSION_ID;
1728
+ if (session?.workerSessionId)
1729
+ env.HQ_SESSION_ID = session.workerSessionId;
1730
+ if (session?.parentSessionId)
1731
+ env.HQ_PARENT_SESSION_ID = session.parentSessionId;
1707
1732
  return env;
1708
1733
  }
1734
+ /** Session ids a worker must not inherit from the process that spawns it. */
1735
+ export const WORKER_SESSION_ENV_VARS = SESSION_ID_ENV_VARS;
1736
+ /**
1737
+ * The worker's fingerprint, built offline from the worker env at lane start.
1738
+ * Null when it cannot be built (no identity and no session id, or an error);
1739
+ * the lane still starts.
1740
+ */
1741
+ export function laneStartFingerprint(hqRoot, env) {
1742
+ return offlineFingerprint(hqRoot, env, "hq lanes");
1743
+ }
1744
+ /** The session identity a lane turn exports to its worker (see `workerEnv`). */
1745
+ export function laneWorkerSessionEnv(lane, workerSessionId) {
1746
+ return {
1747
+ ...(workerSessionId ? { workerSessionId } : {}),
1748
+ ...(lane.senior.kind === "session" && lane.senior.id ? { parentSessionId: lane.senior.id } : {}),
1749
+ };
1750
+ }
1709
1751
  export const WORKER_POLICY_HOOK_CAPABILITY = "# hq-capability: worker-policy-dir v1";
1710
1752
  function installedWorkerPolicyHookSupportsDirectory(hqRoot) {
1711
1753
  const hookPath = path.join(hqRoot, ".claude", "hooks", "inject-policy-on-trigger.sh");
@@ -2445,9 +2487,13 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2445
2487
  const autoCompactTokens = lane.provider === "claude"
2446
2488
  ? providerAutoCompactTokens(config.providers.claude)
2447
2489
  : undefined;
2448
- const childEnv = workerEnv(env, { envVar: resolved.envVar, home: remoteControlHome ?? resolved.home }, lane.lane_id, hqRoot, autoCompactTokens, lane.mesh_company ?? lane.company ?? undefined, workerPolicyDir, lane.registry_worker);
2490
+ const remoteSessionId = existing.remote_control === true
2491
+ ? plan.mode === "resume" ? plan.session_id : randomUUID()
2492
+ : undefined;
2493
+ const childEnv = workerEnv(env, { envVar: resolved.envVar, home: remoteControlHome ?? resolved.home }, lane.lane_id, hqRoot, autoCompactTokens, lane.mesh_company ?? lane.company ?? undefined, workerPolicyDir, lane.registry_worker, undefined, laneWorkerSessionEnv(existing, plan.mode === "resume" ? plan.session_id : remoteSessionId));
2449
2494
  const laneTurnId = randomUUID();
2450
2495
  childEnv.HQ_LANE_TURN_ID = laneTurnId;
2496
+ const fingerprint = (deps.buildFingerprint ?? laneStartFingerprint)(hqRoot, childEnv);
2451
2497
  const model = resolveLaneModel({
2452
2498
  cliModel: pinnedModel,
2453
2499
  profileModel,
@@ -2507,9 +2553,6 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2507
2553
  }),
2508
2554
  }
2509
2555
  : undefined;
2510
- const remoteSessionId = existing.remote_control === true
2511
- ? plan.mode === "resume" ? plan.session_id : randomUUID()
2512
- : undefined;
2513
2556
  const providerCommand = buildProviderCommand(lane.provider, {
2514
2557
  hqRoot,
2515
2558
  briefPath,
@@ -2682,6 +2725,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2682
2725
  pending_settings: undefined,
2683
2726
  account: resolved.id,
2684
2727
  provider_home_at_spawn: remoteControlHome ?? resolved.home,
2728
+ fingerprint,
2685
2729
  working_directory_at_spawn: workerCwd,
2686
2730
  ...(model !== undefined ? { model } : {}),
2687
2731
  ...(model !== undefined
@@ -3094,7 +3138,7 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
3094
3138
  const autoCompactTokens = lane.provider === "claude"
3095
3139
  ? providerAutoCompactTokens(config.providers.claude)
3096
3140
  : undefined;
3097
- const childEnv = workerEnv(env, { envVar: resolved.envVar, home: resolved.home }, lane.lane_id, hqRoot, autoCompactTokens, lane.mesh_company ?? lane.company ?? undefined, workerPolicyDir, lane.registry_worker);
3141
+ const childEnv = workerEnv(env, { envVar: resolved.envVar, home: resolved.home }, lane.lane_id, hqRoot, autoCompactTokens, lane.mesh_company ?? lane.company ?? undefined, workerPolicyDir, lane.registry_worker, undefined, laneWorkerSessionEnv(lane, mode === "resume" && sessionId !== null ? sessionId : undefined));
3098
3142
  const accountCfg = config.providers[lane.provider].accounts[resolved.id];
3099
3143
  const model = resolveLaneModel({
3100
3144
  cliModel: pinnedModel,
@@ -54,6 +54,7 @@ export interface LaneStatusSnapshot {
54
54
  no_story_reason: string | null;
55
55
  state: LaneState;
56
56
  queued_reason: string | null;
57
+ fingerprint: string | null;
57
58
  }>;
58
59
  prs: Array<{
59
60
  story_id: string;
@@ -812,6 +812,7 @@ export function buildLaneStatusSnapshot(input) {
812
812
  no_story_reason: lane.no_story_reason ?? null,
813
813
  state: lane.state,
814
814
  queued_reason: lane.queued_reason ?? null,
815
+ fingerprint: lane.fingerprint ?? null,
815
816
  }));
816
817
  const projectPrs = [];
817
818
  const blockers = [];
@@ -905,7 +906,8 @@ export function formatLaneStatusHuman(snapshot) {
905
906
  const account = worker.account ? " account " + worker.account : "";
906
907
  const queued = worker.queued_reason ? " queued: " + worker.queued_reason : "";
907
908
  return " " + worker.lane_id + " " + worker.state + " " + storyLabel +
908
- " " + worker.provider + model + effort + account + queued;
909
+ " " + worker.provider + model + effort + account + queued +
910
+ "\n fingerprint: " + (worker.fingerprint ?? "-");
909
911
  })),
910
912
  "PRs (may include merged):",
911
913
  ...(snapshot.prs.length === 0
@@ -81,6 +81,10 @@ export declare const LANE_ENVELOPE_JSON_SCHEMA: {
81
81
  readonly type: "string";
82
82
  readonly minLength: 1;
83
83
  };
84
+ readonly fingerprint: {
85
+ readonly type: readonly ["string", "null"];
86
+ readonly description: "Optional worker fingerprint (hq fingerprint self); null when it could not be built. Older readers ignore it.";
87
+ };
84
88
  };
85
89
  readonly allOf: readonly [{
86
90
  readonly if: {
@@ -770,6 +774,12 @@ export interface LaneRecord {
770
774
  */
771
775
  provider_session_id?: string;
772
776
  provider_session_id_absent?: string;
777
+ /**
778
+ * The worker's fingerprint, built offline from the worker env when the
779
+ * lane's run starts (`hq fingerprint self --offline`). Null when it could
780
+ * not be built; absent on lanes started before fingerprints existed.
781
+ */
782
+ fingerprint?: string | null;
773
783
  /** Provider home used by the most recently spawned run, for transcript recovery. */
774
784
  provider_home_at_spawn?: string;
775
785
  /**
@@ -1002,6 +1012,8 @@ export interface LaneEnvelope {
1002
1012
  blocked_reason?: string;
1003
1013
  story_complete?: boolean;
1004
1014
  story_complete_reason?: string;
1015
+ /** The worker's fingerprint, or null when it could not be built. Optional. */
1016
+ fingerprint?: string | null;
1005
1017
  }
1006
1018
  export interface AccountConfig {
1007
1019
  home: string;
@@ -49,6 +49,10 @@ export const LANE_ENVELOPE_JSON_SCHEMA = {
49
49
  blocked_reason: { type: "string" },
50
50
  story_complete: { type: "boolean" },
51
51
  story_complete_reason: { type: "string", minLength: 1 },
52
+ fingerprint: {
53
+ type: ["string", "null"],
54
+ description: "Optional worker fingerprint (hq fingerprint self); null when it could not be built. Older readers ignore it.",
55
+ },
52
56
  },
53
57
  allOf: [
54
58
  {
@@ -243,7 +247,7 @@ export const LANE_FIELD_WRITERS = {
243
247
  overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
244
248
  admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], stop_history: ["command", "reconcile"], settings_history: ["command"], pending_settings: ["command", "spawn"], interrupted_at: ["command", "watcher", "reconcile", "spawn"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
245
249
  quota_reset_at: ["watcher", "reconcile", "spawn", "command"], model_quota_marker: ["watcher", "reconcile"], outpost_command_id: ["command"],
246
- provider_session_id: ["watcher", "command", "spawn"], provider_session_id_absent: ["watcher", "command"], provider_home_at_spawn: ["spawn"], working_directory_at_spawn: ["spawn"], last_resume: ["spawn"], resume_history: ["spawn", "watcher"], last_run_usage: ["watcher"], last_run_usage_at: ["watcher"], envelope_repair: ["watcher", "reconcile"], envelope_validation_errors: ["watcher"], last_progress_event_at: ["watcher"],
250
+ provider_session_id: ["watcher", "command", "spawn"], provider_session_id_absent: ["watcher", "command"], fingerprint: ["spawn"], provider_home_at_spawn: ["spawn"], working_directory_at_spawn: ["spawn"], last_resume: ["spawn"], resume_history: ["spawn", "watcher"], last_run_usage: ["watcher"], last_run_usage_at: ["watcher"], envelope_repair: ["watcher", "reconcile"], envelope_validation_errors: ["watcher"], last_progress_event_at: ["watcher"],
247
251
  };
248
252
  function isPlainObject(value) {
249
253
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -299,6 +303,7 @@ export function parseLaneEnvelope(raw) {
299
303
  "blocked_reason",
300
304
  "story_complete",
301
305
  "story_complete_reason",
306
+ "fingerprint",
302
307
  ].includes(key));
303
308
  if (extra.length > 0) {
304
309
  throw new LaneSchemaError(`Lane envelope rejects unknown properties: ${extra.join(", ")}`, { extra });
@@ -382,6 +387,12 @@ export function parseLaneEnvelope(raw) {
382
387
  };
383
388
  if (nextCheckAt !== undefined)
384
389
  envelope.next_check_at = nextCheckAt;
390
+ if (raw.fingerprint !== undefined) {
391
+ if (raw.fingerprint !== null && (typeof raw.fingerprint !== "string" || raw.fingerprint.trim().length === 0)) {
392
+ throw new LaneSchemaError("fingerprint must be a non-empty string or null", { field: "fingerprint" });
393
+ }
394
+ envelope.fingerprint = raw.fingerprint;
395
+ }
385
396
  if (storyComplete !== undefined) {
386
397
  envelope.story_complete = storyComplete;
387
398
  envelope.story_complete_reason = storyCompleteReason;
@@ -1,4 +1,6 @@
1
1
  export declare function isMonitorFastPathCommand(argv: readonly string[]): boolean;
2
+ /** The commit-msg hook runs `hq fingerprint self --offline`; it must stay fast and offline. */
3
+ export declare function isOfflineFingerprintCommand(argv: readonly string[]): boolean;
2
4
  /** Skip process-wide network lifecycle work for hooks and detached monitor supervisors. */
3
5
  export declare function runGlobalNetworkLifecycle<T>(argv: readonly string[], operation: () => T): T | undefined;
4
6
  //# sourceMappingURL=cli-network-lifecycle.d.ts.map
@@ -2,9 +2,13 @@ const MONITOR_HIDDEN_NETWORK_BYPASS = new Set(["drain", "wait", "enabled", "_run
2
2
  export function isMonitorFastPathCommand(argv) {
3
3
  return argv[2] === "monitor" && MONITOR_HIDDEN_NETWORK_BYPASS.has(argv[3] ?? "");
4
4
  }
5
+ /** The commit-msg hook runs `hq fingerprint self --offline`; it must stay fast and offline. */
6
+ export function isOfflineFingerprintCommand(argv) {
7
+ return argv[2] === "fingerprint" && argv[3] === "self" && argv.slice(4).includes("--offline");
8
+ }
5
9
  /** Skip process-wide network lifecycle work for hooks and detached monitor supervisors. */
6
10
  export function runGlobalNetworkLifecycle(argv, operation) {
7
- if (isMonitorFastPathCommand(argv))
11
+ if (isMonitorFastPathCommand(argv) || isOfflineFingerprintCommand(argv))
8
12
  return undefined;
9
13
  return operation();
10
14
  }
@@ -19,7 +19,8 @@
19
19
  * HQ_VAULT_API_URL — vault-service API Gateway URL
20
20
  */
21
21
  import { isExpiring, isMachineIdentity, type CognitoAuthConfig, type CognitoTokens, type ClientInfo, type VaultServiceConfig } from "@indigoai-us/hq-cloud";
22
- import { type KeychainReadOptions } from "./token-keychain.js";
22
+ import { loadCachedTokens, personTokenCacheFile, readPersonTokenFile } from "./person-token-cache.js";
23
+ export { loadCachedTokens, personTokenCacheFile, readPersonTokenFile };
23
24
  export { isExpiring, isMachineIdentity };
24
25
  /** Person browser/login cache vs machine-creds USER_PASSWORD_AUTH mint. */
25
26
  export type CognitoTokenSource = "person" | "machine";
@@ -118,13 +119,6 @@ export type EnsureCognitoTokenOptions = {
118
119
  /** Cancels bounded, non-interactive person-session refreshes. */
119
120
  signal?: AbortSignal;
120
121
  };
121
- /**
122
- * On-disk person Cognito cache path for `env` (`HQ_STATE_DIR` or `~/.hq`).
123
- * Deliberately does not use hq-cloud's `loadCachedTokens()`: after a machine
124
- * mint in this process, that helper returns in-memory machine tokens when the
125
- * person file is absent, which would falsely block the fresh-box fallback.
126
- */
127
- export declare function personTokenCacheFile(home?: string, env?: NodeJS.ProcessEnv): string;
128
122
  /**
129
123
  * True when a usable person Cognito cache file exists under the effective
130
124
  * `HQ_STATE_DIR` (or `~/.hq`). Presence alone keeps human commands on the
@@ -186,19 +180,6 @@ export declare function formatCognitoSubject(tokens: {
186
180
  idToken?: string;
187
181
  accessToken?: string;
188
182
  } | null | undefined): string;
189
- /**
190
- * Person session for `env`: the Keychain item and the token file, the newer
191
- * by `expiresAt` (see ./token-keychain.ts). Does not consult hq-cloud's
192
- * in-memory fallback, which can surface a different session when the file
193
- * is missing or unreadable.
194
- */
195
- export declare function readPersonTokenFile(home?: string, env?: NodeJS.ProcessEnv, keychain?: KeychainReadOptions): CognitoTokens | null;
196
- /**
197
- * The person's cached session: the shared Keychain item and hq-cloud's file
198
- * loader, the newer by `expiresAt`. Writes still go only to the file in this
199
- * step.
200
- */
201
- export declare function loadCachedTokens(env?: NodeJS.ProcessEnv, keychain?: KeychainReadOptions): CognitoTokens | null;
202
183
  /**
203
184
  * Refresh the active person session and refuse a subject change.
204
185
  *
@@ -25,13 +25,14 @@ import * as os from "os";
25
25
  import * as path from "path";
26
26
  import * as yaml from "js-yaml";
27
27
  import chalk from "chalk";
28
- import { loadCachedTokens as loadCachedTokensFromFile, isExpiring, refreshTokens, saveCachedTokens, browserLogin, detectHqCoreVersion, isMachineIdentity, getValidMachineTokens, CognitoRefreshError, accessTokenFingerprint, invalidateCachedTokensByFingerprint, } from "@indigoai-us/hq-cloud";
28
+ import { isExpiring, refreshTokens, saveCachedTokens, browserLogin, detectHqCoreVersion, isMachineIdentity, getValidMachineTokens, CognitoRefreshError, accessTokenFingerprint, invalidateCachedTokensByFingerprint, } from "@indigoai-us/hq-cloud";
29
29
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
30
30
  import { daemonDir } from "../lib/mesh/live/daemon/paths.js";
31
31
  import { workMeshRoot } from "../lib/mesh/live/paths.js";
32
32
  import { acquireLockFileExclusively } from "./exclusive-lock.js";
33
33
  import { reportNoninteractivePersonRefreshRejection } from "./cognito-refresh-sentry.js";
34
- import { pickNewerSession, readKeychainTokens } from "./token-keychain.js";
34
+ import { decodeJwtPayload, loadCachedTokens, personTokenCacheFile, readPersonTokenFile, readTokenFile, } from "./person-token-cache.js";
35
+ export { loadCachedTokens, personTokenCacheFile, readPersonTokenFile };
35
36
  // Re-export the cached-session primitives for callers that must deliberately
36
37
  // avoid the refresh/login behavior in the higher-level helpers below.
37
38
  export { isExpiring, isMachineIdentity };
@@ -294,17 +295,6 @@ function withAlignedMachineCredsEnv(env, fn) {
294
295
  }
295
296
  }
296
297
  }
297
- /**
298
- * On-disk person Cognito cache path for `env` (`HQ_STATE_DIR` or `~/.hq`).
299
- * Deliberately does not use hq-cloud's `loadCachedTokens()`: after a machine
300
- * mint in this process, that helper returns in-memory machine tokens when the
301
- * person file is absent, which would falsely block the fresh-box fallback.
302
- */
303
- export function personTokenCacheFile(home = os.homedir(), env = process.env) {
304
- const override = env.HQ_STATE_DIR?.trim();
305
- const stateDir = override && override.length > 0 ? override : path.join(home, ".hq");
306
- return path.join(stateDir, "cognito-tokens.json");
307
- }
308
298
  /**
309
299
  * True when a usable person Cognito cache file exists under the effective
310
300
  * `HQ_STATE_DIR` (or `~/.hq`). Presence alone keeps human commands on the
@@ -396,23 +386,6 @@ export function wantsMachineCognitoTokens(options = {}, env = process.env) {
396
386
  export function resolveCognitoTokenSource(env = process.env) {
397
387
  return wantsMachineCognitoTokens({}, env) ? "machine" : "person";
398
388
  }
399
- function decodeJwtPayload(token) {
400
- if (!token)
401
- return null;
402
- const parts = token.split(".");
403
- if (parts.length < 2 || !parts[1])
404
- return null;
405
- try {
406
- const json = Buffer.from(parts[1], "base64url").toString("utf8");
407
- const parsed = JSON.parse(json);
408
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
409
- return null;
410
- return parsed;
411
- }
412
- catch {
413
- return null;
414
- }
415
- }
416
389
  /**
417
390
  * Refresh wrote a different Cognito subject than the active cache.
418
391
  * Callers must not fall through to browser login or machine mint — that
@@ -460,60 +433,6 @@ export function formatCognitoSubject(tokens) {
460
433
  const fromAccess = cognitoSubjectFromToken(tokens.accessToken);
461
434
  return `sub=${fromId.sub ?? fromAccess.sub ?? "-"} entityUid=${fromId.entityUid ?? fromAccess.entityUid ?? "-"}`;
462
435
  }
463
- function readTokenFile(file) {
464
- try {
465
- if (!fs.existsSync(file))
466
- return null;
467
- const raw = JSON.parse(fs.readFileSync(file, "utf8"));
468
- if (typeof raw.accessToken !== "string" || typeof raw.idToken !== "string")
469
- return null;
470
- return raw;
471
- }
472
- catch {
473
- return null;
474
- }
475
- }
476
- /**
477
- * The person session from the shared macOS Keychain item (see
478
- * ./token-keychain.ts), or null. Not read when the token file is missing:
479
- * no file means signed out. Machine-identity and invalidated tokens are
480
- * refused, the same as the file loader.
481
- */
482
- function personKeychainTokens(file, home, env, keychain = {}) {
483
- if (!fs.existsSync(file))
484
- return null;
485
- const tokens = readKeychainTokens({ env, home, ...keychain });
486
- if (!tokens)
487
- return null;
488
- const claims = decodeJwtPayload(tokens.idToken);
489
- const entityType = claims?.["custom:entityType"];
490
- if (typeof entityType === "string" && entityType.trim().length > 0)
491
- return null;
492
- if (fs.existsSync(`${file}.invalid.${accessTokenFingerprint(tokens.accessToken)}`))
493
- return null;
494
- return tokens;
495
- }
496
- /**
497
- * Person session for `env`: the Keychain item and the token file, the newer
498
- * by `expiresAt` (see ./token-keychain.ts). Does not consult hq-cloud's
499
- * in-memory fallback, which can surface a different session when the file
500
- * is missing or unreadable.
501
- */
502
- export function readPersonTokenFile(home = os.homedir(), env = process.env, keychain = {}) {
503
- const file = personTokenCacheFile(home, env);
504
- return pickNewerSession(personKeychainTokens(file, home, env, keychain), readTokenFile(file));
505
- }
506
- /**
507
- * The person's cached session: the shared Keychain item and hq-cloud's file
508
- * loader, the newer by `expiresAt`. Writes still go only to the file in this
509
- * step.
510
- */
511
- export function loadCachedTokens(env = process.env, keychain = {}) {
512
- const home = os.homedir();
513
- const file = personTokenCacheFile(home, env);
514
- const fromFile = loadCachedTokensFromFile();
515
- return pickNewerSession(personKeychainTokens(file, home, env, keychain), fromFile);
516
- }
517
436
  function restoreTokenFile(file, before) {
518
437
  if (before) {
519
438
  const dir = path.dirname(file);
@@ -0,0 +1,32 @@
1
+ import { type CognitoTokens } from "@indigoai-us/hq-cloud";
2
+ import { type KeychainReadOptions } from "./token-keychain.js";
3
+ /**
4
+ * On-disk person Cognito cache path for `env` (`HQ_STATE_DIR` or `~/.hq`).
5
+ * Deliberately does not use hq-cloud's `loadCachedTokens()`: after a machine
6
+ * mint in this process, that helper returns in-memory machine tokens when the
7
+ * person file is absent, which would falsely block the fresh-box fallback.
8
+ */
9
+ export declare function personTokenCacheFile(home?: string, env?: NodeJS.ProcessEnv): string;
10
+ export declare function decodeJwtPayload(token: string | undefined): Record<string, unknown> | null;
11
+ export declare function readTokenFile(file: string): CognitoTokens | null;
12
+ /**
13
+ * The person session from the shared macOS Keychain item (see
14
+ * ./token-keychain.ts), or null. Not read when the token file is missing:
15
+ * no file means signed out. Machine-identity and invalidated tokens are
16
+ * refused, the same as the file loader.
17
+ */
18
+ export declare function personKeychainTokens(file: string, home: string, env: NodeJS.ProcessEnv, keychain?: KeychainReadOptions): CognitoTokens | null;
19
+ /**
20
+ * Person session for `env`: the Keychain item and the token file, the newer
21
+ * by `expiresAt` (see ./token-keychain.ts). Does not consult hq-cloud's
22
+ * in-memory fallback, which can surface a different session when the file
23
+ * is missing or unreadable.
24
+ */
25
+ export declare function readPersonTokenFile(home?: string, env?: NodeJS.ProcessEnv, keychain?: KeychainReadOptions): CognitoTokens | null;
26
+ /**
27
+ * The person's cached session: the shared Keychain item and hq-cloud's file
28
+ * loader, the newer by `expiresAt`. Writes still go only to the file in this
29
+ * step.
30
+ */
31
+ export declare function loadCachedTokens(env?: NodeJS.ProcessEnv, keychain?: KeychainReadOptions): CognitoTokens | null;
32
+ //# sourceMappingURL=person-token-cache.d.ts.map