@indigoai-us/hq-cli 5.243.0 → 5.244.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 +23 -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/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/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,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.244.0] — 2026-09-27
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- The setup bot no longer posts "This one takes a minute. I'll reply here as
|
|
10
|
+
soon as it's done." when a step runs long. Other bots keep their notice.
|
|
11
|
+
- After the bundled setup bot connects its first app, it says "Great, <Tool> is
|
|
12
|
+
connected" with a link to the company's integrations page on hq.computer.
|
|
13
|
+
- The bundled setup bot invites teammates through the new-hire flow when HQ
|
|
14
|
+
has it, and tells the person to have the invitee check their spam folder.
|
|
15
|
+
|
|
5
16
|
## [5.243.0] — 2026-09-27
|
|
6
17
|
### Added
|
|
7
18
|
|
|
@@ -1630,6 +1641,18 @@
|
|
|
1630
1641
|
|
|
1631
1642
|
## [Unreleased]
|
|
1632
1643
|
|
|
1644
|
+
### Fixed
|
|
1645
|
+
|
|
1646
|
+
- A bot whose agent home is on a network share can now mint a token. The
|
|
1647
|
+
refresh lock was a POSIX hard-link lock, and CIFS/SMB shares — Azure Files
|
|
1648
|
+
again — do not implement `link()` at all, so every mint failed with
|
|
1649
|
+
ENOTSUP and took the whole probe down with it: no heartbeat ever posted
|
|
1650
|
+
and the inbox poller retried and failed every 15 seconds. The lock now
|
|
1651
|
+
falls back to an exclusive create, which gives the same guarantee and
|
|
1652
|
+
works there. Contention still surfaces as EEXIST on both paths, so
|
|
1653
|
+
stale-owner reclamation is unchanged, and a link that fails for any other
|
|
1654
|
+
reason is still a real error.
|
|
1655
|
+
|
|
1633
1656
|
- `hq mesh daemon install` and `hq mesh daemon run` unload and remove the
|
|
1634
1657
|
legacy `ai.getindigo.hq-work-mesh-listen` unit (LaunchAgent, systemd user
|
|
1635
1658
|
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
|
|
@@ -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"
|
|
@@ -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
|