@indigoai-us/hq-cli 5.243.0 → 5.245.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.245.0] — 2026-09-27
6
+
7
+ ### Fixed
8
+
9
+ - Work Mesh sessions registered without a bound project (unresolved,
10
+ needs_project, needs_task) no longer store the register receipt as their
11
+ binding episode, and `hq mesh context bind-project` clears it. The server
12
+ dropped every session-event batch carrying that id, so turn and session ends
13
+ never landed and no work report posted. Existing state files are repaired on
14
+ the next flush.
15
+
16
+ ## [5.244.0] — 2026-09-27
17
+
18
+ ### Fixed
19
+
20
+ - A bot whose agent home is on a network share can now mint a token. The
21
+ refresh lock was a POSIX hard-link lock, and CIFS/SMB shares — Azure Files
22
+ again — do not implement `link()` at all, so every mint failed with
23
+ ENOTSUP and took the whole probe down with it: no heartbeat ever posted
24
+ and the inbox poller retried and failed every 15 seconds. The lock now
25
+ falls back to an exclusive create, which gives the same guarantee and
26
+ works there. Contention still surfaces as EEXIST on both paths, so
27
+ stale-owner reclamation is unchanged, and a link that fails for any other
28
+ reason is still a real error.
29
+
30
+ ### Changed
31
+
32
+ - The setup bot no longer posts "This one takes a minute. I'll reply here as
33
+ soon as it's done." when a step runs long. Other bots keep their notice.
34
+ - After the bundled setup bot connects its first app, it says "Great, <Tool> is
35
+ connected" with a link to the company's integrations page on hq.computer.
36
+ - The bundled setup bot invites teammates through the new-hire flow when HQ
37
+ has it, and tells the person to have the invitee check their spam folder.
38
+
5
39
  ## [5.243.0] — 2026-09-27
6
40
  ### Added
7
41
 
@@ -1630,6 +1664,8 @@
1630
1664
 
1631
1665
  ## [Unreleased]
1632
1666
 
1667
+ ### Fixed
1668
+
1633
1669
  - `hq mesh daemon install` and `hq mesh daemon run` unload and remove the
1634
1670
  legacy `ai.getindigo.hq-work-mesh-listen` unit (LaunchAgent, systemd user
1635
1671
  unit, or Scheduled Task) so only the daemon subscribes. The daemon writes
@@ -114,16 +114,29 @@ console, and they sign in in the browser.
114
114
 
115
115
  ## Inviting a teammate
116
116
 
117
+ Every invite runs through HQ's new-hire flow. Read
118
+ `.claude/skills/new-hire/SKILL.md` in the HQ folder before the first invite and
119
+ follow its steps: check `hq members list` first, set the role, invite, then
120
+ the access that person needs (groups, shared apps and keys) and the
121
+ acceptance follow-through. It is your playbook, not something to show them:
122
+ ask its questions in plain words, one at a time, skip what setup has not
123
+ created yet (groups, an onboarding packet), and never name the skill or its
124
+ command. If that file is not in this HQ, the steps below are enough.
125
+
117
126
  1. Ask for one email and whether they should be a member or an admin
118
- (member is the default; admins can only invite members).
127
+ (member is the default; admins can only invite members). If they already
128
+ said who, do not ask again.
119
129
  2. Confirm in one line that an invitation email will go to that address, and
120
130
  wait for yes.
121
- 3. Run `hq members invite <email> --company <slug> --role member`.
131
+ 3. Send the invite the way the new-hire flow says
132
+ (`hq members invite <email> --company <slug> --role member`).
122
133
  4. Tell them what happens next: the teammate opens the email, signs in with
123
134
  Google or Microsoft, and gets the company in their own HQ, so their Claude
124
135
  Code or Codex knows the business from their first session. Say what they
125
- will see that matters to this person (the connected calls, the client
126
- homes).
136
+ will see that matters to this person (the client homes, and any app you
137
+ have shared with them; a connected app stays private to the person who
138
+ connected it until it is shared). Always add: "Tell them to check their
139
+ spam folder if the email doesn't show up in a few minutes."
127
140
  5. Check with `hq members list` (pending until they accept). If they never
128
141
  got it, `--resend` sends it again.
129
142
 
@@ -142,6 +142,17 @@ the way a good engineer would, and then do it:
142
142
  what it means for their team (see "What HQ is" in your instructions). A
143
143
  connection is not done until this read works. Never say "connected",
144
144
  "done" or "everything is set up" before it does.
145
+ 6. **After the first connection only, say where connections live.** Once
146
+ the read works for the first app they connect, open your reply with the
147
+ confirmation and the link, in these words:
148
+ "Great, <Tool> is connected. You can see everything that's connected at
149
+ https://hq.computer/companies/<company-slug>/integrations, or ask me or
150
+ any other AI in HQ to connect another." Use the company's slug from the
151
+ Owner context (for a personal connection, the link is
152
+ https://hq.computer/personal/integrations). Then the real data from step
153
+ 5 and the next question. Say this once in the whole setup. For every
154
+ later connection, skip it: show the real data from step 5 and move on.
155
+ Give the link again only if they ask where connections live.
145
156
 
146
157
  If a step needs something only they can do (an admin approval, a paid plan),
147
158
  say exactly what, record it in the progress note as waiting, and move on to
@@ -42,13 +42,12 @@ import { defaultRunCommand } from "../lib/service-manager/launchd.js";
42
42
  import { workContextRoot } from "../lib/work-context/paths.js";
43
43
  import { formatMigrateConfirmation, submitSessionMigration, } from "../lib/work-context/migrate.js";
44
44
  import { formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, submitOrganizeDecision, } from "../lib/work-context/organize.js";
45
- import { readSessionState, updateSessionState } from "../lib/work-context/state.js";
45
+ import { applyOperatorProjectBinding, readSessionState, updateSessionState, } from "../lib/work-context/state.js";
46
46
  import { findLocalPrd, listNeedsProjectSessions } from "../lib/work-context/bind-best.js";
47
47
  import { bindCallingSession, formatSessionBoundLine } from "../lib/work-context/bind-calling-session.js";
48
48
  import { rebindNeedsProjectOnPrompt } from "../lib/work-context/prompt-rebind.js";
49
49
  import { planPrdSync, rebindSessionToProject } from "../lib/work-context/prd-sync.js";
50
50
  import { reserveSessionSeq } from "../lib/mesh/live/session-seq.js";
51
- import { WORK_CONTEXT_CONTRACT_VERSION } from "../lib/work-context/contract.js";
52
51
  import { loadObservationFromFile, markUntracked, parseObservationJson, reconcileObservation, } from "../lib/work-context/reconcile.js";
53
52
  import { resolveCompany } from "../lib/work-context/company.js";
54
53
  import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../lib/work-context/repo-remote.js";
@@ -835,20 +834,7 @@ async function runBindProject(target, opts) {
835
834
  fail(`No candidate ${projectId} on session ${sessionId}.`);
836
835
  projectId = chosen.projectId;
837
836
  }
838
- const updated = updateSessionState(sessionId, root, (state) => {
839
- if (!state)
840
- return null;
841
- return {
842
- ...state,
843
- contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
844
- contextStatus: state.taskId ? "bound" : "needs_task",
845
- projectId,
846
- projectGate: undefined,
847
- projectCandidates: undefined,
848
- projectBindReason: `operator selected ${projectId}`,
849
- updatedAt: new Date().toISOString(),
850
- };
851
- });
837
+ const updated = updateSessionState(sessionId, root, (state) => state ? applyOperatorProjectBinding(state, projectId) : null);
852
838
  if (!updated)
853
839
  fail(`No work-context state for session ${sessionId}.`);
854
840
  if (opts.json) {
@@ -27,9 +27,6 @@ export declare const PROGRESS_COALESCE_MS = 2000;
27
27
  export declare const PROGRESS_MIN_GAP_MS = 4000;
28
28
  export declare const WORKING_NOTICE_MS = 60000;
29
29
  export declare const WORKING_NOTICE_TEXT = "Working on it \u2014 I'll reply here when it's done.";
30
- /** The setup bot's heads-up, sent once when a turn runs past SETUP_WORKING_NOTICE_MS. */
31
- export declare const SETUP_WORKING_NOTICE_MS = 30000;
32
- export declare const SETUP_WORKING_NOTICE_TEXT = "This one takes a minute. I'll reply here as soon as it's done.";
33
30
  export interface ProgressPosterOptions {
34
31
  /** Post one message; resolve true when it was delivered. */
35
32
  send: (body: string) => Promise<boolean>;
@@ -27,9 +27,6 @@ export const PROGRESS_COALESCE_MS = 2_000;
27
27
  export const PROGRESS_MIN_GAP_MS = 4_000;
28
28
  export const WORKING_NOTICE_MS = 60_000;
29
29
  export const WORKING_NOTICE_TEXT = "Working on it — I'll reply here when it's done.";
30
- /** The setup bot's heads-up, sent once when a turn runs past SETUP_WORKING_NOTICE_MS. */
31
- export const SETUP_WORKING_NOTICE_MS = 30_000;
32
- export const SETUP_WORKING_NOTICE_TEXT = "This one takes a minute. I'll reply here as soon as it's done.";
33
30
  /** Whitespace-insensitive form used to recognise repeated text. */
34
31
  export function normalizeForCompare(text) {
35
32
  return text.replace(/\s+/g, "");
@@ -57,7 +57,7 @@ import { ensureBotSessionMeta } from "./company-bind.js";
57
57
  import { readBotCredsIdentity } from "./creds.js";
58
58
  import { isProcessed, markProcessed, readInboxState } from "./inbox-state.js";
59
59
  import { clearInflight, patchInflight, readInflight, readInflightTurns, writeInflight } from "./inflight.js";
60
- import { containsNoReplySentinel, ProgressPoster, SETUP_WORKING_NOTICE_MS, SETUP_WORKING_NOTICE_TEXT, } from "./progress.js";
60
+ import { containsNoReplySentinel, ProgressPoster, } from "./progress.js";
61
61
  import { splitBody } from "./split.js";
62
62
  import { createBotLogger } from "./log.js";
63
63
  import { buildSystemPrompt, introDmText, readPromptSources } from "./prompt.js";
@@ -737,12 +737,11 @@ export async function runBot(deps) {
737
737
  };
738
738
  const poster = new ProgressPoster({
739
739
  // The setup bot talks to someone new to HQ: its notes to itself never
740
- // reach them, and a long step gets a heads-up instead of silence.
741
- ...(isSetupBot
742
- ? { dropPreToolMessages: true, workingNoticeMs: SETUP_WORKING_NOTICE_MS, workingNoticeText: SETUP_WORKING_NOTICE_TEXT }
743
- : {}),
740
+ // reach them, and it posts no "still working" notice either — the
741
+ // desktop app shows that the bot is working.
742
+ ...(isSetupBot ? { dropPreToolMessages: true } : {}),
744
743
  ...deps.progress,
745
- ...(args.workingNotice === false || silenceable ? { workingNoticeMs: 0 } : {}),
744
+ ...(args.workingNotice === false || silenceable || isSetupBot ? { workingNoticeMs: 0 } : {}),
746
745
  ...(silenceable ? { buffer: true } : {}),
747
746
  send: makeSendFn(args.send),
748
747
  ...(args.sendFinal ? { sendFinal: makeSendFn(args.sendFinal) } : {}),
@@ -1458,7 +1457,6 @@ export async function runBot(deps) {
1458
1457
  tReceived,
1459
1458
  send,
1460
1459
  sendFinal,
1461
- // The setup bot's own notice (SETUP_WORKING_NOTICE_TEXT) is set where the poster is built.
1462
1460
  silenceable: target.kind === "room" && !target.mentioned,
1463
1461
  ...(readDirs.length ? { readDirs } : {}),
1464
1462
  ...(target.kind === "room"
@@ -690,32 +690,46 @@ export async function reconcileObservation(obs, deps) {
690
690
  registerClassification = "needs_project";
691
691
  diagnostics.push(`company rebind to ${resolvedCompany.slug ?? resolvedCompany.uid}; cleared project`);
692
692
  }
693
+ // Acked register operations for this session. A register that did not come
694
+ // back bound yields no binding episode, so its receipt (not the session's
695
+ // bindingEpisodeId) is what identifies an already acknowledged binding.
696
+ const ackedRegisters = prior
697
+ ? listOutboxOperations(deps.root).filter((operation) => operation.kind === "register" &&
698
+ operation.delivery === "acked" &&
699
+ operation.sessionId === sessionId &&
700
+ Boolean(operation.receiptId))
701
+ : [];
693
702
  const stateClassification = registerClassification === "unresolved" &&
694
703
  prior?.contextStatus === "needs_project" &&
695
704
  prior.companyUid === resolvedCompany.uid &&
696
- prior.bindingEpisodeId
705
+ (prior.bindingEpisodeId ||
706
+ ackedRegisters.some((operation) => operation.companyUid === resolvedCompany.uid))
697
707
  ? "needs_project"
698
708
  : registerClassification;
699
709
  // Re-observing an already acknowledged binding is normal for hooks and the
700
710
  // resident daemon. A fresh clientOperationId must not turn that observation
701
711
  // into a second semantic register operation: the prior receipt is the stable
702
712
  // identity for this exact company/project/task binding.
703
- const acknowledgedPrior = prior?.bindingEpisodeId &&
713
+ const priorBindingIsBound = Boolean(projectId && taskId);
714
+ const acknowledgedReceipt = prior &&
704
715
  prior.companyUid === resolvedCompany.uid &&
705
716
  prior.companySlug === resolvedCompany.slug &&
706
717
  prior.projectId === projectId &&
707
- prior.taskId === taskId &&
708
- listOutboxOperations(deps.root).some((operation) => operation.kind === "register" &&
709
- operation.delivery === "acked" &&
710
- operation.sessionId === sessionId &&
711
- operation.receiptId === prior.bindingEpisodeId &&
718
+ prior.taskId === taskId
719
+ ? ackedRegisters.find((operation) => (priorBindingIsBound
720
+ ? operation.receiptId === prior.bindingEpisodeId
721
+ : !prior.bindingEpisodeId || operation.receiptId === prior.bindingEpisodeId) &&
712
722
  operation.companyUid === resolvedCompany.uid &&
713
723
  operation.companySlug === resolvedCompany.slug &&
714
724
  operation.projectId === projectId &&
715
- operation.taskId === taskId)
716
- ? prior
717
- : null;
725
+ operation.taskId === taskId)?.receiptId
726
+ : undefined;
727
+ const acknowledgedPrior = acknowledgedReceipt ? prior : null;
718
728
  if (acknowledgedPrior) {
729
+ // A partial (not bound) acknowledged register carries no server episode.
730
+ const retainedEpisodeId = priorBindingIsBound
731
+ ? acknowledgedPrior.bindingEpisodeId
732
+ : undefined;
719
733
  // Activity is local progress, not binding identity. Retain the receipt
720
734
  // without another register while carrying hook counters forward so the
721
735
  // normal clarification threshold still advances.
@@ -727,7 +741,7 @@ export async function reconcileObservation(obs, deps) {
727
741
  contextStatus: stateClassification,
728
742
  projectId,
729
743
  taskId,
730
- bindingEpisodeId: acknowledgedPrior.bindingEpisodeId,
744
+ bindingEpisodeId: retainedEpisodeId,
731
745
  updatedAt: nowIso,
732
746
  startedAt,
733
747
  ...(typeof toolWrites === "number" ? { toolWrites } : {}),
@@ -735,7 +749,7 @@ export async function reconcileObservation(obs, deps) {
735
749
  ...(decision ? { decision } : {}),
736
750
  ...bindStateFields,
737
751
  ...transcriptFields,
738
- }, prior), deps.root);
752
+ }, prior, { preserveBindingEpisode: priorBindingIsBound }), deps.root);
739
753
  const kind = registerClassification === "bound"
740
754
  ? "bound"
741
755
  : registerClassification === "needs_task"
@@ -743,7 +757,7 @@ export async function reconcileObservation(obs, deps) {
743
757
  : registerClassification === "needs_project"
744
758
  ? "needs_project"
745
759
  : "queued";
746
- diagnostics.push(`binding unchanged; retained receipt=${acknowledgedPrior.bindingEpisodeId}`);
760
+ diagnostics.push(`binding unchanged; retained receipt=${acknowledgedReceipt}`);
747
761
  return {
748
762
  result: resultOf({
749
763
  kind,
@@ -755,7 +769,7 @@ export async function reconcileObservation(obs, deps) {
755
769
  companySlug: resolvedCompany.slug,
756
770
  projectId,
757
771
  taskId,
758
- bindingEpisodeId: acknowledgedPrior.bindingEpisodeId,
772
+ ...(retainedEpisodeId ? { bindingEpisodeId: retainedEpisodeId } : {}),
759
773
  ...bindResultFields,
760
774
  }),
761
775
  exitCode: EXIT_OK,
@@ -174,4 +174,12 @@ export declare function authoritativeCompanyFromState(state: SessionStateFile |
174
174
  slug?: string;
175
175
  uid?: string;
176
176
  } | null;
177
+ /**
178
+ * Apply `hq mesh context bind-project`: an operator's local project choice.
179
+ *
180
+ * The server issues no binding episode for this local choice, so any prior
181
+ * bindingEpisodeId (older clients stored a register receipt there) is cleared.
182
+ * Carrying it forward makes the server drop every later session-event batch.
183
+ */
184
+ export declare function applyOperatorProjectBinding(state: SessionStateFile, projectId: string, now?: () => Date): SessionStateFile;
177
185
  //# sourceMappingURL=state.d.ts.map
@@ -367,6 +367,19 @@ export function writeAcknowledgedRegisterBinding(binding, root, now = () => new
367
367
  const acknowledgementStrength = acknowledged === "bound" ? 3 : acknowledged === "needs_task" ? 2 : 1;
368
368
  const priorStrength = bindingStrength(prior);
369
369
  const preservePriorBinding = Boolean(prior.companyUid === binding.companyUid && priorStrength > acknowledgementStrength);
370
+ // The server issues no binding episode for a register that did not come
371
+ // back bound (unresolved / needs_project / needs_task). Its receipt must
372
+ // never stand in for one: session-event batches carry bindingEpisodeId and
373
+ // the server drops batches whose episode it does not recognise. Older
374
+ // clients stored that receipt here, so also repair a prior value that is
375
+ // exactly this partial register's receipt.
376
+ const receiptEpisode = acknowledged === "bound" ? binding.receiptId : undefined;
377
+ const priorEpisode = acknowledged !== "bound" && prior.bindingEpisodeId === binding.receiptId
378
+ ? undefined
379
+ : prior.bindingEpisodeId;
380
+ const sameBindingAsPrior = prior.companyUid === binding.companyUid &&
381
+ prior.projectId === binding.projectId &&
382
+ prior.taskId === binding.taskId;
370
383
  const next = mergeLocalSessionFields({
371
384
  contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
372
385
  sessionId: binding.sessionId,
@@ -376,10 +389,10 @@ export function writeAcknowledgedRegisterBinding(binding, root, now = () => new
376
389
  projectId: preservePriorBinding ? prior.projectId : binding.projectId,
377
390
  taskId: preservePriorBinding ? prior.taskId : binding.taskId,
378
391
  bindingEpisodeId: preservePriorBinding
379
- ? prior.bindingEpisodeId ?? binding.receiptId
380
- : binding.receiptId,
392
+ ? priorEpisode ?? receiptEpisode
393
+ : receiptEpisode ?? (sameBindingAsPrior ? priorEpisode : undefined),
381
394
  updatedAt: now().toISOString(),
382
- }, prior);
395
+ }, prior, { preserveBindingEpisode: false });
383
396
  if (prior.companyUid === next.companyUid &&
384
397
  prior.companySlug === next.companySlug &&
385
398
  prior.contextStatus === next.contextStatus &&
@@ -418,4 +431,24 @@ export function authoritativeCompanyFromState(state) {
418
431
  return null;
419
432
  return { slug: state.companySlug, uid: state.companyUid };
420
433
  }
434
+ /**
435
+ * Apply `hq mesh context bind-project`: an operator's local project choice.
436
+ *
437
+ * The server issues no binding episode for this local choice, so any prior
438
+ * bindingEpisodeId (older clients stored a register receipt there) is cleared.
439
+ * Carrying it forward makes the server drop every later session-event batch.
440
+ */
441
+ export function applyOperatorProjectBinding(state, projectId, now = () => new Date()) {
442
+ return {
443
+ ...state,
444
+ contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
445
+ contextStatus: state.taskId ? "bound" : "needs_task",
446
+ projectId,
447
+ bindingEpisodeId: undefined,
448
+ projectGate: undefined,
449
+ projectCandidates: undefined,
450
+ projectBindReason: `operator selected ${projectId}`,
451
+ updatedAt: now().toISOString(),
452
+ };
453
+ }
421
454
  //# sourceMappingURL=state.js.map
@@ -29,6 +29,7 @@ import { loadCachedTokens, isExpiring, refreshTokens, saveCachedTokens, browserL
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
+ import { acquireLockFileExclusively } from "./exclusive-lock.js";
32
33
  // Re-export the cached-session primitives for callers that must deliberately
33
34
  // avoid the refresh/login behavior in the higher-level helpers below.
34
35
  export { isExpiring, isMachineIdentity, loadCachedTokens };
@@ -557,7 +558,7 @@ async function withAbortablePersonRefreshLock(signal, fn) {
557
558
  for (;;) {
558
559
  throwIfAborted(signal);
559
560
  try {
560
- fs.linkSync(candidate, lockFile);
561
+ acquireLockFileExclusively(candidate, lockFile, stateDir);
561
562
  held = true;
562
563
  break;
563
564
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Acquiring a lock file on a filesystem that may not do hard links.
3
+ *
4
+ * The refresh lock is a classic POSIX `link()` lock: write a candidate file,
5
+ * hard-link it into place, and let EEXIST mean somebody else holds it. That
6
+ * is atomic on a local disk and on NFS, and it is simply absent on CIFS/SMB
7
+ * — Azure Files returns ENOTSUP for `link()` on every call, so a bot whose
8
+ * agent home lives on such a share could never mint a token at all. Its
9
+ * probe failed every line with:
10
+ *
11
+ * ENOTSUP: operation not supported on socket, link
12
+ * '.../cognito-tokens.json.lock.candidate.48560...' -> '.../lock'
13
+ *
14
+ * An exclusive create (`open(..., O_EXCL)`) gives the same guarantee — the
15
+ * server refuses the second creator — and works on those shares. So the link
16
+ * is tried first, and a filesystem that says it cannot do links is remembered
17
+ * and switched to the exclusive-create path for the rest of the process.
18
+ *
19
+ * Both paths fail with EEXIST when the lock is held, so contention, stale-
20
+ * owner reclamation and release are unchanged for every caller.
21
+ */
22
+ /** Test seam: forget what we learned about which filesystems do links. */
23
+ export declare function resetLinkSupportCache(): void;
24
+ /**
25
+ * Create `lockFile` exclusively, or throw EEXIST if it already exists.
26
+ *
27
+ * `candidate` is an already-written file holding the owner's pid; on a
28
+ * link-capable filesystem it becomes the lock, and otherwise its contents
29
+ * are written directly. Either way the lock ends up holding the same bytes.
30
+ */
31
+ export declare function acquireLockFileExclusively(candidate: string, lockFile: string, dirKey: string): void;
32
+ //# sourceMappingURL=exclusive-lock.d.ts.map
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Acquiring a lock file on a filesystem that may not do hard links.
3
+ *
4
+ * The refresh lock is a classic POSIX `link()` lock: write a candidate file,
5
+ * hard-link it into place, and let EEXIST mean somebody else holds it. That
6
+ * is atomic on a local disk and on NFS, and it is simply absent on CIFS/SMB
7
+ * — Azure Files returns ENOTSUP for `link()` on every call, so a bot whose
8
+ * agent home lives on such a share could never mint a token at all. Its
9
+ * probe failed every line with:
10
+ *
11
+ * ENOTSUP: operation not supported on socket, link
12
+ * '.../cognito-tokens.json.lock.candidate.48560...' -> '.../lock'
13
+ *
14
+ * An exclusive create (`open(..., O_EXCL)`) gives the same guarantee — the
15
+ * server refuses the second creator — and works on those shares. So the link
16
+ * is tried first, and a filesystem that says it cannot do links is remembered
17
+ * and switched to the exclusive-create path for the rest of the process.
18
+ *
19
+ * Both paths fail with EEXIST when the lock is held, so contention, stale-
20
+ * owner reclamation and release are unchanged for every caller.
21
+ */
22
+ import * as fs from "fs";
23
+ /**
24
+ * `link()` errnos that mean "this filesystem does not implement links"
25
+ * rather than "the link failed". EEXIST is deliberately NOT here: that is
26
+ * ordinary contention and the caller's own retry loop owns it.
27
+ */
28
+ const LINK_UNSUPPORTED_CODES = new Set([
29
+ "ENOTSUP",
30
+ "EOPNOTSUPP",
31
+ "EPERM",
32
+ "EXDEV",
33
+ "EACCES",
34
+ ]);
35
+ /**
36
+ * Per-process, per-directory memory of a filesystem that cannot hard-link,
37
+ * so the cost is one failed call rather than one per acquisition.
38
+ */
39
+ const linkUnsupportedDirs = new Set();
40
+ /** Test seam: forget what we learned about which filesystems do links. */
41
+ export function resetLinkSupportCache() {
42
+ linkUnsupportedDirs.clear();
43
+ }
44
+ /**
45
+ * Create `lockFile` exclusively, or throw EEXIST if it already exists.
46
+ *
47
+ * `candidate` is an already-written file holding the owner's pid; on a
48
+ * link-capable filesystem it becomes the lock, and otherwise its contents
49
+ * are written directly. Either way the lock ends up holding the same bytes.
50
+ */
51
+ export function acquireLockFileExclusively(candidate, lockFile, dirKey) {
52
+ if (!linkUnsupportedDirs.has(dirKey)) {
53
+ try {
54
+ fs.linkSync(candidate, lockFile);
55
+ return;
56
+ }
57
+ catch (error) {
58
+ const code = error.code ?? "";
59
+ // Contention is the caller's business, and an unrecognised failure is
60
+ // a real fault: neither one means "try the other strategy".
61
+ if (code === "EEXIST")
62
+ throw error;
63
+ if (!LINK_UNSUPPORTED_CODES.has(code))
64
+ throw error;
65
+ linkUnsupportedDirs.add(dirKey);
66
+ }
67
+ }
68
+ // O_EXCL: the create fails with EEXIST if anyone got there first, which is
69
+ // exactly the signal the link path produced.
70
+ fs.writeFileSync(lockFile, fs.readFileSync(candidate), {
71
+ flag: "wx",
72
+ mode: 0o600,
73
+ });
74
+ }
75
+ //# sourceMappingURL=exclusive-lock.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.243.0",
3
+ "version": "5.245.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {