@indigoai-us/hq-cli 5.345.134 → 5.345.136

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 (100) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/assets/scaffold/core/scripts/worktree-gc.sh +98 -8
  3. package/dist/command-catalog.generated.d.ts +62 -6
  4. package/dist/command-catalog.generated.js +79 -7
  5. package/dist/command-registration-plan.d.ts +6 -0
  6. package/dist/command-registration-plan.js +1 -0
  7. package/dist/commands/core-bucket-c-handoff-knowledge-native.d.ts +3 -1
  8. package/dist/commands/core-bucket-c-handoff-knowledge-native.js +8 -4
  9. package/dist/commands/core-checkpoint-native.d.ts +10 -0
  10. package/dist/commands/core-checkpoint-native.js +60 -15
  11. package/dist/commands/core-handoff.d.ts +2 -0
  12. package/dist/commands/core-handoff.js +3 -1
  13. package/dist/commands/core-pipeline-workflow-runner-native.js +7 -5
  14. package/dist/commands/daemon.js +3 -1
  15. package/dist/commands/fingerprint.d.ts +10 -0
  16. package/dist/commands/fingerprint.js +191 -0
  17. package/dist/commands/integrations-core.d.ts +22 -0
  18. package/dist/commands/integrations-core.js +25 -0
  19. package/dist/commands/integrations-manage.d.ts +24 -1
  20. package/dist/commands/integrations-manage.js +92 -17
  21. package/dist/commands/integrations.d.ts +11 -0
  22. package/dist/commands/integrations.js +100 -19
  23. package/dist/commands/lanes-loop.d.ts +9 -0
  24. package/dist/commands/lanes-loop.js +9 -1
  25. package/dist/commands/lanes.js +78 -55
  26. package/dist/commands/meetings.d.ts +2 -0
  27. package/dist/commands/meetings.js +57 -14
  28. package/dist/commands/mesh.d.ts +19 -1
  29. package/dist/commands/mesh.js +158 -70
  30. package/dist/commands/reindex.js +80 -5
  31. package/dist/commands/storage-offload.d.ts +93 -28
  32. package/dist/commands/storage-offload.js +290 -34
  33. package/dist/commands/storage.js +60 -7
  34. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2550 -1748
  35. package/dist/hq-cloud-sync-runner/package.json +1 -1
  36. package/dist/index.js +4 -0
  37. package/dist/lib/capacity/route.js +24 -0
  38. package/dist/lib/core-utils/session-company.d.ts +11 -0
  39. package/dist/lib/core-utils/session-company.js +34 -0
  40. package/dist/lib/core-utils/worktree-resume.js +44 -4
  41. package/dist/lib/core-utils/worktree-session-owner.d.ts +27 -0
  42. package/dist/lib/core-utils/worktree-session-owner.js +157 -0
  43. package/dist/lib/daemon/catalog.d.ts +2 -0
  44. package/dist/lib/daemon/catalog.js +3 -3
  45. package/dist/lib/daemon/hq-child-bootstrap-stage.d.ts +5 -0
  46. package/dist/lib/daemon/hq-child-bootstrap-stage.js +3 -11
  47. package/dist/lib/daemon/server.js +10 -1
  48. package/dist/lib/daemon/supervisor.js +6 -0
  49. package/dist/lib/daemon/sync-runner-bundle.d.ts +5 -0
  50. package/dist/lib/daemon/sync-runner-bundle.js +4 -12
  51. package/dist/lib/doctor/checks/claude-wiring.js +2 -1
  52. package/dist/lib/doctor/fix/apply.d.ts +20 -0
  53. package/dist/lib/doctor/fix/apply.js +63 -3
  54. package/dist/lib/doctor/fix/remediation.d.ts +1 -1
  55. package/dist/lib/doctor/fix/remediation.js +16 -0
  56. package/dist/lib/doctor/shared.d.ts +11 -1
  57. package/dist/lib/doctor/shared.js +16 -2
  58. package/dist/lib/fingerprint/fixtures.d.ts +19 -0
  59. package/dist/lib/fingerprint/fixtures.js +61 -0
  60. package/dist/lib/fingerprint/format.d.ts +50 -0
  61. package/dist/lib/fingerprint/format.js +105 -0
  62. package/dist/lib/fingerprint/offline.d.ts +16 -0
  63. package/dist/lib/fingerprint/offline.js +44 -0
  64. package/dist/lib/fingerprint/registry-client.d.ts +74 -0
  65. package/dist/lib/fingerprint/registry-client.js +141 -0
  66. package/dist/lib/fingerprint/resolve-local.d.ts +14 -0
  67. package/dist/lib/fingerprint/resolve-local.js +67 -0
  68. package/dist/lib/fingerprint/self-core.d.ts +139 -0
  69. package/dist/lib/fingerprint/self-core.js +371 -0
  70. package/dist/lib/fingerprint/self.d.ts +44 -0
  71. package/dist/lib/fingerprint/self.js +191 -0
  72. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  73. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  74. package/dist/lib/lanes/monitor.js +14 -2
  75. package/dist/lib/lanes/spawn.d.ts +19 -1
  76. package/dist/lib/lanes/spawn.js +50 -6
  77. package/dist/lib/lanes/story-ledger.d.ts +1 -0
  78. package/dist/lib/lanes/story-ledger.js +3 -1
  79. package/dist/lib/lanes/types.d.ts +12 -0
  80. package/dist/lib/lanes/types.js +12 -1
  81. package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
  82. package/dist/lib/mesh/live/daemon/install.js +22 -0
  83. package/dist/lib/mesh/live/flush.js +106 -3
  84. package/dist/lib/mesh/live/session-events-client.d.ts +9 -0
  85. package/dist/lib/mesh/live/session-events-client.js +15 -0
  86. package/dist/lib/work-context/state.d.ts +10 -0
  87. package/dist/lib/work-context/state.js +5 -0
  88. package/dist/utils/cli-network-lifecycle.d.ts +2 -0
  89. package/dist/utils/cli-network-lifecycle.js +5 -1
  90. package/dist/utils/cognito-session.d.ts +2 -21
  91. package/dist/utils/cognito-session.js +3 -84
  92. package/dist/utils/fsync-directory.d.ts +17 -0
  93. package/dist/utils/fsync-directory.js +27 -0
  94. package/dist/utils/person-token-cache.d.ts +32 -0
  95. package/dist/utils/person-token-cache.js +95 -0
  96. package/dist/utils/vault-api.d.ts +21 -1
  97. package/dist/utils/vault-api.js +54 -5
  98. package/dist/utils/windows-hide-default.d.ts +42 -0
  99. package/dist/utils/windows-hide-default.js +98 -0
  100. package/package.json +2 -2
@@ -354,12 +354,24 @@ const WATCH_RUNTIME_EXECUTABLES = ["bun", "deno", "node", "nodejs", "ts-node", "
354
354
  // on the raw line, then validate whatever precedes it as an hq invocation.
355
355
  // A path form (absolute or ./, ../) is required so an unrelated leading
356
356
  // command such as "vim /tmp/hq" is not absorbed into the path.
357
+ //
358
+ // Package managers that install the bin through a shell shim (pnpm, some
359
+ // volta/yarn setups) exec the runtime with the package entrypoint instead of
360
+ // the hq symlink, so the ps line reads
361
+ // "node .../node_modules/@indigoai-us/hq-cli/dist/index.js lanes watch ...".
362
+ // Accept that entrypoint path as an hq executable too.
363
+ const HQ_CLI_ENTRYPOINT_SUFFIX = "/hq-cli/dist/index.js";
357
364
  function isHqExecutablePath(candidate) {
358
365
  if (candidate === "hq")
359
366
  return true;
360
- if (!/^\.{0,2}\//u.test(candidate) || path.basename(candidate) !== "hq") {
367
+ if (!/^\.{0,2}\//u.test(candidate))
368
+ return false;
369
+ const isHqBin = path.basename(candidate) === "hq";
370
+ const isHqEntrypoint = candidate
371
+ .replace(/\\/gu, "/")
372
+ .endsWith(HQ_CLI_ENTRYPOINT_SUFFIX);
373
+ if (!isHqBin && !isHqEntrypoint)
361
374
  return false;
362
- }
363
375
  return !candidate.split(" ").some((piece) => piece.startsWith("-"));
364
376
  }
365
377
  function isHqInvocationPrefix(prefix) {
@@ -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;
@@ -121,6 +121,18 @@ export declare function detectPlatform(platform?: NodeJS.Platform): DaemonInstal
121
121
  * binary when both exist.
122
122
  */
123
123
  export declare function preferStableHqBinary(entry: string, platform?: DaemonInstallPlatform, existsSync?: typeof fs.existsSync): string;
124
+ /**
125
+ * A path that `node <path>` can run. preferStableHqBinary maps the package
126
+ * entry to the npm `hq.cmd` shim on Windows, which is right for a task that
127
+ * launches through cmd.exe but wrong for every caller that runs
128
+ * `node <hqBinary>`: node parses the batch file as JavaScript and exits with
129
+ * "SyntaxError: Invalid or unexpected token" on `@ECHO off`
130
+ * (feedback_a60339e8, feedback_516e4427). Map the shim back to the package's
131
+ * JS entry, which sits beside it in the same npm prefix and so stays valid
132
+ * across `npm i -g` updates. Anything that is not a Windows command shim is
133
+ * returned unchanged.
134
+ */
135
+ export declare function nodeRunnableHqEntry(hqBinary: string, platform?: DaemonInstallPlatform | NodeJS.Platform, existsSync?: typeof fs.existsSync): string;
124
136
  /**
125
137
  * Absolute path of the CLI entry that is executing this process.
126
138
  * realpath of argv[1] only — never a PATH lookup of `hq`.
@@ -299,6 +299,28 @@ export function preferStableHqBinary(entry, platform = detectPlatform(), existsS
299
299
  }
300
300
  return entry;
301
301
  }
302
+ /** JS entry of the hq-cli package, relative to an npm global prefix. */
303
+ const HQ_CLI_PACKAGE_ENTRY = ["node_modules", "@indigoai-us", "hq-cli", "dist", "index.js"];
304
+ /**
305
+ * A path that `node <path>` can run. preferStableHqBinary maps the package
306
+ * entry to the npm `hq.cmd` shim on Windows, which is right for a task that
307
+ * launches through cmd.exe but wrong for every caller that runs
308
+ * `node <hqBinary>`: node parses the batch file as JavaScript and exits with
309
+ * "SyntaxError: Invalid or unexpected token" on `@ECHO off`
310
+ * (feedback_a60339e8, feedback_516e4427). Map the shim back to the package's
311
+ * JS entry, which sits beside it in the same npm prefix and so stays valid
312
+ * across `npm i -g` updates. Anything that is not a Windows command shim is
313
+ * returned unchanged.
314
+ */
315
+ export function nodeRunnableHqEntry(hqBinary, platform = detectPlatform(), existsSync = fs.existsSync) {
316
+ if (platform !== "win32")
317
+ return hqBinary;
318
+ const ext = path.win32.extname(hqBinary).toLowerCase();
319
+ if (ext !== ".cmd" && ext !== ".bat" && ext !== ".ps1")
320
+ return hqBinary;
321
+ const entry = path.win32.join(path.win32.dirname(hqBinary), ...HQ_CLI_PACKAGE_ENTRY);
322
+ return existsSync(entry) ? entry : hqBinary;
323
+ }
302
324
  /**
303
325
  * Absolute path of the CLI entry that is executing this process.
304
326
  * realpath of argv[1] only — never a PATH lookup of `hq`.
@@ -22,7 +22,7 @@ import { defaultSleep, FLUSH_MAX_ATTEMPTS, fullJitterDelayMs, } from "./backoff.
22
22
  import { LOCAL_ONLY_FIELDS, stripLocalOnlyFields } from "./format-spool-line.js";
23
23
  import { enrichSessionEndEvent } from "./report-summary.js";
24
24
  import { createWorkLocationResolver, withWorkLocation, } from "./work-location.js";
25
- import { SESSION_EVENTS_BATCH_MAX, WRITABLE_CONTEXT_STATUSES, parseSessionEventsBatchAck, } from "./session-events-client.js";
25
+ import { SESSION_EVENTS_BATCH_MAX, WRITABLE_CONTEXT_STATUSES, isSessionOwnerMismatch, parseSessionEventsBatchAck, SESSION_OWNER_MISMATCH, } from "./session-events-client.js";
26
26
  import { appendDeadLetterLine, appendDroppedReceipt, appendHeldLinesIfAbsent, appendSpoolLine, claimHeld, claimSpool, countDistinctHeldEvents, countJsonlLines, recoverOrphanClaims, removeClaimFile, } from "./spool.js";
27
27
  import { workMeshHeldPath, workMeshRoot } from "./paths.js";
28
28
  /** Bound a replayed session backlog so one flush cannot burst hundreds of events. */
@@ -306,7 +306,7 @@ export async function flushSessionEvents(inputDeps) {
306
306
  v: 1,
307
307
  sessionId,
308
308
  dropped: remainingItems.length,
309
- reason: "untracked",
309
+ reason: decision.reason,
310
310
  at: now().toISOString(),
311
311
  }, deps.workMeshRoot);
312
312
  summary.dropped += remainingItems.length;
@@ -353,6 +353,22 @@ export async function flushSessionEvents(inputDeps) {
353
353
  accountBatchSuccess(result.body, originalChunk, deps.workMeshRoot, now, summary, markDisposed);
354
354
  continue;
355
355
  }
356
+ if (!result.retryable && isSessionOwnerMismatch(result.status, result.body)) {
357
+ // Another actor already reports this session (feedback_5b4683c2).
358
+ // Not an error on this machine: drop the session's remaining events
359
+ // with a receipt, remember the answer, and skip it from now on.
360
+ const unsent = remainingItems.filter((item) => pendingRestore.has(item.event));
361
+ dropOwnerMismatchedSession({
362
+ sessionId,
363
+ companyUid: decision.companyUid,
364
+ items: unsent,
365
+ deps,
366
+ now,
367
+ summary,
368
+ markDisposed,
369
+ });
370
+ continue sessionLoop;
371
+ }
356
372
  if (!result.retryable && result.status === 404) {
357
373
  const rejectedProjectId = decision.projectId;
358
374
  updateSessionState(sessionId, deps.workContextRoot, (current) => {
@@ -560,15 +576,34 @@ function accountBatchSuccess(body, originalChunk, meshRoot, now, summary, markDi
560
576
  }
561
577
  const rejectedIds = new Set();
562
578
  const at = now().toISOString();
579
+ let ownerMismatchDropped = 0;
563
580
  for (const row of ack.rejected) {
564
581
  const item = byId.get(row.eventId);
565
582
  if (!item)
566
583
  continue;
584
+ if (row.code === SESSION_OWNER_MISMATCH) {
585
+ ownerMismatchDropped += 1;
586
+ summary.dropped += 1;
587
+ rejectedIds.add(row.eventId);
588
+ markDisposed(item.event);
589
+ continue;
590
+ }
567
591
  appendDeadLetterLine(deadLetterEnvelope(item.event, row.code, row.code, at, row.code), meshRoot);
568
592
  summary.deadLettered += 1;
569
593
  rejectedIds.add(row.eventId);
570
594
  markDisposed(item.event);
571
595
  }
596
+ if (ownerMismatchDropped > 0) {
597
+ const sessionId = sessionIdOf(originalChunk[0].event);
598
+ appendDroppedReceipt({
599
+ v: 1,
600
+ sessionId,
601
+ dropped: ownerMismatchDropped,
602
+ reason: "owner_mismatch",
603
+ code: SESSION_OWNER_MISMATCH,
604
+ at,
605
+ }, meshRoot);
606
+ }
572
607
  const remaining = originalChunk.filter((item) => {
573
608
  const id = eventIdOf(item.event);
574
609
  return !id || !rejectedIds.has(id);
@@ -767,6 +802,26 @@ async function postPresenceLeaveIfNeeded(input) {
767
802
  });
768
803
  if (!result.ok) {
769
804
  const at = input.now().toISOString();
805
+ if (!result.retryable && isSessionOwnerMismatch(result.status, result.body)) {
806
+ // The old company says another actor owns this session, so there is no
807
+ // presence of ours to leave. Drop the leave events, clear the pending
808
+ // leave, and let the remaining events continue to the new binding.
809
+ dropOwnerMismatchedSession({
810
+ sessionId: input.sessionId,
811
+ companyUid: leave.companyUid,
812
+ items: leaveItems,
813
+ deps: input.deps,
814
+ now: input.now,
815
+ summary: input.summary,
816
+ markDisposed: input.markDisposed,
817
+ clearPresenceLeave: true,
818
+ });
819
+ return {
820
+ state: readSessionState(input.sessionId, input.deps.workContextRoot) ?? input.state,
821
+ items: remaining,
822
+ abort: false,
823
+ };
824
+ }
770
825
  if (!result.retryable) {
771
826
  for (const item of input.items) {
772
827
  appendDeadLetterLine(deadLetterEnvelope(item.event, `HTTP_${result.status}`, result.status, at), input.deps.workMeshRoot);
@@ -843,12 +898,60 @@ function rebindUnboundSession(sessionId, items, state, deps, nowMs) {
843
898
  }
844
899
  return readSessionState(sessionId, deps.workContextRoot) ?? state;
845
900
  }
901
+ const loggedOwnerMismatch = new Set();
902
+ /**
903
+ * Drop a session's events after the server answered 403
904
+ * SESSION_OWNER_MISMATCH: write one dropped receipt, mark the session state so
905
+ * later flushes skip it without posting, and log once per session. These
906
+ * events never reach dead-letter, so daemon health stays clean.
907
+ */
908
+ function dropOwnerMismatchedSession(input) {
909
+ const at = input.now().toISOString();
910
+ if (input.items.length > 0) {
911
+ appendDroppedReceipt({
912
+ v: 1,
913
+ sessionId: input.sessionId,
914
+ dropped: input.items.length,
915
+ reason: "owner_mismatch",
916
+ code: SESSION_OWNER_MISMATCH,
917
+ companyUid: input.companyUid,
918
+ at,
919
+ }, input.deps.workMeshRoot);
920
+ }
921
+ input.summary.dropped += input.items.length;
922
+ for (const item of input.items)
923
+ input.markDisposed(item.event);
924
+ updateSessionState(input.sessionId, input.deps.workContextRoot, (current) => {
925
+ if (!current)
926
+ return current;
927
+ if (input.clearPresenceLeave) {
928
+ const { presenceLeave: _leave, ...rest } = current;
929
+ return { ...rest, updatedAt: at };
930
+ }
931
+ return {
932
+ ...current,
933
+ ownerMismatch: { companyUid: input.companyUid, at },
934
+ updatedAt: at,
935
+ };
936
+ });
937
+ const key = `${input.deps.workContextRoot}\0${input.sessionId}`;
938
+ if (!loggedOwnerMismatch.has(key)) {
939
+ loggedOwnerMismatch.add(key);
940
+ input.deps.log?.(`work-mesh: session ${input.sessionId} is reported by another actor (403 SESSION_OWNER_MISMATCH); dropped ${input.items.length} event(s), not dead-lettered`);
941
+ }
942
+ }
846
943
  function classifySession(state) {
847
944
  if (!state) {
848
945
  return { action: "hold", heldReason: "STATE_ABSENT" };
849
946
  }
850
947
  if (state.contextStatus === "untracked") {
851
- return { action: "drop" };
948
+ return { action: "drop", reason: "untracked" };
949
+ }
950
+ // The server already told us another actor owns this session in this
951
+ // company; posting again would only earn another 403.
952
+ if (state.ownerMismatch?.companyUid &&
953
+ state.companyUid === state.ownerMismatch.companyUid) {
954
+ return { action: "drop", reason: "owner_mismatch" };
852
955
  }
853
956
  if (state.contextStatus === "needs_company") {
854
957
  return { action: "hold", heldReason: "NEEDS_COMPANY" };
@@ -41,6 +41,15 @@ export interface SessionEventsBatchAck {
41
41
  */
42
42
  export declare function parseSessionEventsBatchAck(body: unknown): SessionEventsBatchAck | null;
43
43
  export type SessionEventsPoster = (body: SessionEventsBatchBody) => Promise<SessionEventsPostResult>;
44
+ /**
45
+ * Server code for a session that another actor in the same company already
46
+ * reports (for example a local bot's own headless turn, picked up by the
47
+ * person's daemon). The server will never accept this actor's events for that
48
+ * session, so the flush drops them with a receipt instead of dead-lettering.
49
+ */
50
+ export declare const SESSION_OWNER_MISMATCH = "SESSION_OWNER_MISMATCH";
51
+ /** True for the 403 SESSION_OWNER_MISMATCH answer (feedback_5b4683c2). */
52
+ export declare function isSessionOwnerMismatch(status: number, body: unknown): boolean;
44
53
  export declare function classifySessionEventsResponse(status: number, networkError?: boolean): Pick<SessionEventsPostResult, "ok" | "retryable" | "networkError">;
45
54
  /**
46
55
  * Build a poster that uses vaultApiFetch + Cognito token.
@@ -65,6 +65,21 @@ export function parseSessionEventsBatchAck(body) {
65
65
  }
66
66
  return { accepted, duplicates, rejected };
67
67
  }
68
+ /**
69
+ * Server code for a session that another actor in the same company already
70
+ * reports (for example a local bot's own headless turn, picked up by the
71
+ * person's daemon). The server will never accept this actor's events for that
72
+ * session, so the flush drops them with a receipt instead of dead-lettering.
73
+ */
74
+ export const SESSION_OWNER_MISMATCH = "SESSION_OWNER_MISMATCH";
75
+ /** True for the 403 SESSION_OWNER_MISMATCH answer (feedback_5b4683c2). */
76
+ export function isSessionOwnerMismatch(status, body) {
77
+ if (status !== 403)
78
+ return false;
79
+ if (!body || typeof body !== "object" || Array.isArray(body))
80
+ return false;
81
+ return body.code === SESSION_OWNER_MISMATCH;
82
+ }
68
83
  export function classifySessionEventsResponse(status, networkError = false) {
69
84
  if (networkError || status === 0) {
70
85
  return { ok: false, retryable: true, networkError: true };
@@ -117,6 +117,16 @@ export interface SessionStateFile {
117
117
  taskId?: string;
118
118
  bindingEpisodeId?: string;
119
119
  };
120
+ /**
121
+ * LOCAL-ONLY. Set when the server answered 403 SESSION_OWNER_MISMATCH for
122
+ * this session in `companyUid`: another actor already reports it (a local
123
+ * bot's own turn, a checkpoint helper). While the session stays bound to
124
+ * that company, flush drops its events locally instead of posting them.
125
+ */
126
+ ownerMismatch?: {
127
+ companyUid: string;
128
+ at: string;
129
+ };
120
130
  updatedAt: string;
121
131
  }
122
132
  /**
@@ -215,6 +215,8 @@ function projectSessionState(state) {
215
215
  out.projectHint = state.projectHint;
216
216
  if (state.presenceLeave?.companyUid)
217
217
  out.presenceLeave = state.presenceLeave;
218
+ if (state.ownerMismatch?.companyUid)
219
+ out.ownerMismatch = state.ownerMismatch;
218
220
  return out;
219
221
  }
220
222
  /**
@@ -282,6 +284,9 @@ export function mergeLocalSessionFields(next, prior, options = {}) {
282
284
  if (!out.presenceLeave && prior.presenceLeave) {
283
285
  out.presenceLeave = prior.presenceLeave;
284
286
  }
287
+ if (!out.ownerMismatch && prior.ownerMismatch) {
288
+ out.ownerMismatch = prior.ownerMismatch;
289
+ }
285
290
  if (out.projectId)
286
291
  out.projectHint = undefined;
287
292
  const sameBinding = prior.companyUid === out.companyUid &&
@@ -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
  *