@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 +36 -0
- package/assets/bot-workers/setup/skills/agents-and-team.md +17 -4
- package/assets/bot-workers/setup/skills/your-business.md +11 -0
- package/dist/commands/mesh.js +2 -16
- package/dist/lib/bot/progress.d.ts +0 -3
- package/dist/lib/bot/progress.js +0 -3
- package/dist/lib/bot/run.js +5 -7
- package/dist/lib/work-context/reconcile.js +28 -14
- package/dist/lib/work-context/state.d.ts +8 -0
- package/dist/lib/work-context/state.js +36 -3
- package/dist/utils/cognito-session.js +2 -1
- package/dist/utils/exclusive-lock.d.ts +32 -0
- package/dist/utils/exclusive-lock.js +75 -0
- package/package.json +1 -1
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.
|
|
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
|
|
126
|
-
|
|
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
|
package/dist/commands/mesh.js
CHANGED
|
@@ -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>;
|
package/dist/lib/bot/progress.js
CHANGED
|
@@ -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, "");
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -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,
|
|
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
|
|
741
|
-
|
|
742
|
-
|
|
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
|
|
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
|
-
|
|
709
|
-
operation.
|
|
710
|
-
operation.
|
|
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
|
-
|
|
717
|
-
|
|
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:
|
|
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=${
|
|
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:
|
|
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
|
-
?
|
|
380
|
-
:
|
|
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
|
-
|
|
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
|