@indigoai-us/hq-cli 5.108.10 → 5.108.12
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 +62 -0
- package/dist/commands/dm.d.ts +64 -0
- package/dist/commands/dm.js +192 -3
- package/dist/lib/doctor/checks/runtime-probe.d.ts +29 -4
- package/dist/lib/doctor/checks/runtime-probe.js +41 -7
- package/dist/utils/client-health-contract.d.ts +10 -1
- package/dist/utils/client-health-contract.js +2 -0
- package/dist/utils/client-health.js +22 -18
- package/dist/utils/self-update.js +8 -1
- package/dist/utils/version-gate.d.ts +26 -0
- package/dist/utils/version-gate.js +54 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,8 +2,70 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.108.12] - 2026-09-05
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `hq dm send` into a channel now turns `@name` in the message into a REAL
|
|
10
|
+
mention. HQ Rooms mentions are structured-only — the server never reads
|
|
11
|
+
`@name` out of the body — so until now a CLI post like
|
|
12
|
+
`hq dm send '#hq-dev' "@Izzy please look"` went in with no `mentions` array
|
|
13
|
+
and mentioned nobody: no notification, no agent wake. During an incident that
|
|
14
|
+
was a silent false negative, and it made agent-in-channel workflows
|
|
15
|
+
undrivable from the CLI. The send path now reads the channel roster
|
|
16
|
+
(`GET /v1/notify/channels/{id}/members`), resolves each `@name`
|
|
17
|
+
case-insensitively against display name, slug/handle, email local part, and
|
|
18
|
+
first name, and posts the structured `mentions` array with `participantType`
|
|
19
|
+
derived from the uid prefix (`agt_*` → agent, otherwise human). Quote a
|
|
20
|
+
multi-word name as `@"Jacob Posel"`. A name that is unknown or matches more
|
|
21
|
+
than one member FAILS the send and lists the roster or the candidates rather
|
|
22
|
+
than posting something that looks addressed but isn't; a bare `@` or an email
|
|
23
|
+
address in prose is never a mention. `--no-mentions` posts the text literally.
|
|
24
|
+
|
|
25
|
+
## [5.108.11] — 2026-09-05
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- `hq doctor` agents-v2 (hermes) self-attestation now keys hook-adapter wiring
|
|
30
|
+
on the **runtime config**, not `.claude/settings.json` (#512). The 5.108.10
|
|
31
|
+
fix required `.claude/settings.json` to name `hq-agents-v2-hook-adapter.sh`,
|
|
32
|
+
but on a real hermes box it never does: the v2 runtime's shell-hook dispatcher
|
|
33
|
+
reads a `hooks:` block from `~/.hermes/config.yaml` that invokes the adapter,
|
|
34
|
+
and the adapter merely *reads* `.claude/settings.json` to fan out to the
|
|
35
|
+
classic `hook-gate.sh` hooks. On the v2.17 canary `settings.json` had 0
|
|
36
|
+
adapter references / 94 `hook-gate.sh` references while `~/.hermes/config.yaml`
|
|
37
|
+
wired the adapter across its lifecycle events, so the old check could never
|
|
38
|
+
attest. The runtime probe now requires the on-box adapter installed under the
|
|
39
|
+
tree at `.agents-v2-hooks/hq-agents-v2-hook-adapter.sh` **and** the runtime
|
|
40
|
+
config at `${HQ_HERMES_CONFIG_FILE:-~/.hermes/config.yaml}` invoking it. All
|
|
41
|
+
three attestation signals are still required, and no non-agents-v2 host's
|
|
42
|
+
verdict changes. TypeScript twin of `hq_runtime_config_wires_v2_adapter` in
|
|
43
|
+
`check-hq-hooks.sh`.
|
|
44
|
+
- `hq` self-update and the version-gate now name a non-writable npm prefix
|
|
45
|
+
plainly instead of failing opaquely (#511). When a global update fails because
|
|
46
|
+
the npm prefix is root-owned and the running user cannot write it — the
|
|
47
|
+
agent-box case, where the CLI is a `/usr` global install and the runtime is
|
|
48
|
+
unprivileged — both update surfaces now print that the prefix is not writable
|
|
49
|
+
and point at the paths that *can* update it (the box's `hq-cli-update` timer or
|
|
50
|
+
`sudo npm install -g @indigoai-us/hq-cli@latest`), rather than leaving
|
|
51
|
+
`exit 75` to read as a generic error.
|
|
52
|
+
|
|
5
53
|
## [5.108.10] — 2026-09-05
|
|
6
54
|
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- `hq doctor` now self-attests hook enforcement on agents-v2 (hermes) fleet
|
|
58
|
+
hosts, which host detection leaves platform-unknown. When the runtime is
|
|
59
|
+
agents-v2 (the runtime marker reports `agents-v2`, or the on-box hook adapter
|
|
60
|
+
is installed under the tree), `.claude/settings.json` wires that on-box
|
|
61
|
+
adapter, and a policy-trigger ledger evidences a live turn (the exact
|
|
62
|
+
session's ledger under `--session-id`, otherwise any ledger fresh within the
|
|
63
|
+
freshness window), the runtime probe reports platform `agents-v2` and PASS
|
|
64
|
+
instead of UNKNOWN — because the on-box adapter provably wrote the ledger
|
|
65
|
+
through the same `.claude` hooks. This is the TypeScript twin of
|
|
66
|
+
`agents_v2_attested` in `check-hq-hooks.sh`; all three signals are required, so
|
|
67
|
+
no non-agents-v2 host's verdict changes.
|
|
68
|
+
|
|
7
69
|
## [5.108.9] — 2026-09-05
|
|
8
70
|
|
|
9
71
|
## [5.108.8] — 2026-09-05
|
package/dist/commands/dm.d.ts
CHANGED
|
@@ -57,6 +57,70 @@ export interface ChannelSummary {
|
|
|
57
57
|
* unit-testable.
|
|
58
58
|
*/
|
|
59
59
|
export declare function matchChannelsByName(channels: ChannelSummary[], name: string): ChannelSummary[];
|
|
60
|
+
/** Server-side cap on one post's mentions (hq-pro MAX_CHANNEL_MENTIONS). */
|
|
61
|
+
export declare const MAX_CHANNEL_MENTIONS = 25;
|
|
62
|
+
/**
|
|
63
|
+
* One roster row from GET /v1/notify/channels/{id}/members. The server enriches
|
|
64
|
+
* each CHAN_MEMBER row with live display identity (displayName, email, …).
|
|
65
|
+
*/
|
|
66
|
+
export interface ChannelMember {
|
|
67
|
+
personUid: string;
|
|
68
|
+
displayName?: string;
|
|
69
|
+
email?: string;
|
|
70
|
+
slug?: string;
|
|
71
|
+
handle?: string;
|
|
72
|
+
}
|
|
73
|
+
/** A structured mention as the POST messages route expects it. */
|
|
74
|
+
export interface ChannelMention {
|
|
75
|
+
participantUid: string;
|
|
76
|
+
participantType: "agent" | "human";
|
|
77
|
+
displayName: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* `agt_*` uids are agents, everything else (`prs_*`) is a human. The server
|
|
81
|
+
* 400s INVALID_MENTIONS when the type and the uid prefix disagree, so this is
|
|
82
|
+
* derived, never guessed.
|
|
83
|
+
*/
|
|
84
|
+
export declare function mentionParticipantType(uid: string): "agent" | "human";
|
|
85
|
+
/**
|
|
86
|
+
* Extract `@name` tokens from a message body — leading or inline. A token is an
|
|
87
|
+
* `@` at a WORD BOUNDARY (start of body, or after whitespace or an opening
|
|
88
|
+
* bracket/quote) followed by a name run. Quote the `@` for a multi-word display
|
|
89
|
+
* name: `@"Jacob Posel"`. Pure → unit-testable.
|
|
90
|
+
*
|
|
91
|
+
* 'hi @Izzy please look' → ["Izzy"]
|
|
92
|
+
* '@"Jacob Posel" ping' → ["Jacob Posel"]
|
|
93
|
+
* 'email a@b.com' → [] (not a word boundary)
|
|
94
|
+
* 'cost is @ the top' → [] (bare @)
|
|
95
|
+
* '@izzy and @Izzy' → ["izzy"] (deduped case-insensitively)
|
|
96
|
+
*
|
|
97
|
+
* Trailing punctuation is trimmed ("@Izzy," → "Izzy"), so a name is never
|
|
98
|
+
* polluted by the sentence around it.
|
|
99
|
+
*/
|
|
100
|
+
export declare function parseMentionTokens(body: string): string[];
|
|
101
|
+
/**
|
|
102
|
+
* The names one roster row answers to, lowercased: its display name, the slug of
|
|
103
|
+
* that display name, any server-supplied slug/handle, the email local part, and
|
|
104
|
+
* the first word of a multi-word display name (so `@Izzy` reaches "Izzy
|
|
105
|
+
* Rivera"). A key shared by two rows makes the token AMBIGUOUS, which fails the
|
|
106
|
+
* send — never a silent pick. Pure → unit-testable.
|
|
107
|
+
*/
|
|
108
|
+
export declare function memberMatchKeys(member: ChannelMember): string[];
|
|
109
|
+
export type MentionResolution = {
|
|
110
|
+
ok: true;
|
|
111
|
+
mentions: ChannelMention[];
|
|
112
|
+
} | {
|
|
113
|
+
ok: false;
|
|
114
|
+
error: string;
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Resolve `@name` tokens against the channel roster into the structured
|
|
118
|
+
* `mentions` array. Case-insensitive on display name and slug/handle. Anything
|
|
119
|
+
* ambiguous (several roster rows answer to the name) or unresolvable (none do)
|
|
120
|
+
* FAILS with a message naming the candidates or the roster — the caller must
|
|
121
|
+
* never post a message whose "@" text mentions nobody. Pure → unit-testable.
|
|
122
|
+
*/
|
|
123
|
+
export declare function resolveMentions(tokens: string[], members: ChannelMember[]): MentionResolution;
|
|
60
124
|
/**
|
|
61
125
|
* Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
|
|
62
126
|
* Returns null on anything that doesn't match. Pure → unit-testable.
|
package/dist/commands/dm.js
CHANGED
|
@@ -101,6 +101,157 @@ export function matchChannelsByName(channels, name) {
|
|
|
101
101
|
return false;
|
|
102
102
|
});
|
|
103
103
|
}
|
|
104
|
+
// ─── Channel @mentions ───────────────────────────────────────────────────────
|
|
105
|
+
//
|
|
106
|
+
// HQ Rooms mentions are STRUCTURED ONLY: the server never parses `@name` out of
|
|
107
|
+
// the message body (see hq-pro src/vault-service/lib/room-agent-delivery.ts and
|
|
108
|
+
// parseChannelMentions / resolveChannelMentions in
|
|
109
|
+
// src/vault-service/handlers/notify-dm.ts). A post whose body says "@Izzy please
|
|
110
|
+
// look" but carries no `mentions` array mentions NOBODY — no notification, no
|
|
111
|
+
// agent wake. That is a silent false negative during an incident, so the CLI
|
|
112
|
+
// resolves the names itself and sends the structured array. Resolution failures
|
|
113
|
+
// FAIL the send rather than posting a message that looks addressed but isn't.
|
|
114
|
+
/** Server-side cap on one post's mentions (hq-pro MAX_CHANNEL_MENTIONS). */
|
|
115
|
+
export const MAX_CHANNEL_MENTIONS = 25;
|
|
116
|
+
/**
|
|
117
|
+
* `agt_*` uids are agents, everything else (`prs_*`) is a human. The server
|
|
118
|
+
* 400s INVALID_MENTIONS when the type and the uid prefix disagree, so this is
|
|
119
|
+
* derived, never guessed.
|
|
120
|
+
*/
|
|
121
|
+
export function mentionParticipantType(uid) {
|
|
122
|
+
return uid.startsWith("agt_") ? "agent" : "human";
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Extract `@name` tokens from a message body — leading or inline. A token is an
|
|
126
|
+
* `@` at a WORD BOUNDARY (start of body, or after whitespace or an opening
|
|
127
|
+
* bracket/quote) followed by a name run. Quote the `@` for a multi-word display
|
|
128
|
+
* name: `@"Jacob Posel"`. Pure → unit-testable.
|
|
129
|
+
*
|
|
130
|
+
* 'hi @Izzy please look' → ["Izzy"]
|
|
131
|
+
* '@"Jacob Posel" ping' → ["Jacob Posel"]
|
|
132
|
+
* 'email a@b.com' → [] (not a word boundary)
|
|
133
|
+
* 'cost is @ the top' → [] (bare @)
|
|
134
|
+
* '@izzy and @Izzy' → ["izzy"] (deduped case-insensitively)
|
|
135
|
+
*
|
|
136
|
+
* Trailing punctuation is trimmed ("@Izzy," → "Izzy"), so a name is never
|
|
137
|
+
* polluted by the sentence around it.
|
|
138
|
+
*/
|
|
139
|
+
export function parseMentionTokens(body) {
|
|
140
|
+
const pattern = /(^|[\s([{<])@(?:"([^"\n]{1,80})"|([A-Za-z0-9][A-Za-z0-9._-]*))/g;
|
|
141
|
+
const names = [];
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
let match;
|
|
144
|
+
while ((match = pattern.exec(body)) !== null) {
|
|
145
|
+
const raw = match[2] ?? match[3] ?? "";
|
|
146
|
+
// Only a bare name run can pick up sentence punctuation; a quoted name is
|
|
147
|
+
// taken exactly as written.
|
|
148
|
+
const name = (match[2] !== undefined ? raw : raw.replace(/[._-]+$/, "")).trim();
|
|
149
|
+
if (!name)
|
|
150
|
+
continue;
|
|
151
|
+
const key = name.toLowerCase();
|
|
152
|
+
if (seen.has(key))
|
|
153
|
+
continue;
|
|
154
|
+
seen.add(key);
|
|
155
|
+
names.push(name);
|
|
156
|
+
}
|
|
157
|
+
return names;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The names one roster row answers to, lowercased: its display name, the slug of
|
|
161
|
+
* that display name, any server-supplied slug/handle, the email local part, and
|
|
162
|
+
* the first word of a multi-word display name (so `@Izzy` reaches "Izzy
|
|
163
|
+
* Rivera"). A key shared by two rows makes the token AMBIGUOUS, which fails the
|
|
164
|
+
* send — never a silent pick. Pure → unit-testable.
|
|
165
|
+
*/
|
|
166
|
+
export function memberMatchKeys(member) {
|
|
167
|
+
const keys = new Set();
|
|
168
|
+
const add = (value) => {
|
|
169
|
+
const v = (value ?? "").trim().toLowerCase();
|
|
170
|
+
if (v)
|
|
171
|
+
keys.add(v);
|
|
172
|
+
};
|
|
173
|
+
const displayName = (member.displayName ?? "").trim();
|
|
174
|
+
add(displayName);
|
|
175
|
+
add(channelSlug(displayName));
|
|
176
|
+
add(member.slug);
|
|
177
|
+
add(member.handle);
|
|
178
|
+
add((member.email ?? "").split("@")[0]);
|
|
179
|
+
const firstWord = displayName.split(/\s+/)[0];
|
|
180
|
+
if (firstWord && firstWord !== displayName) {
|
|
181
|
+
add(firstWord);
|
|
182
|
+
add(channelSlug(firstWord));
|
|
183
|
+
}
|
|
184
|
+
return [...keys];
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Resolve `@name` tokens against the channel roster into the structured
|
|
188
|
+
* `mentions` array. Case-insensitive on display name and slug/handle. Anything
|
|
189
|
+
* ambiguous (several roster rows answer to the name) or unresolvable (none do)
|
|
190
|
+
* FAILS with a message naming the candidates or the roster — the caller must
|
|
191
|
+
* never post a message whose "@" text mentions nobody. Pure → unit-testable.
|
|
192
|
+
*/
|
|
193
|
+
export function resolveMentions(tokens, members) {
|
|
194
|
+
if (tokens.length === 0)
|
|
195
|
+
return { ok: true, mentions: [] };
|
|
196
|
+
const byKey = new Map();
|
|
197
|
+
for (const member of members) {
|
|
198
|
+
if (!member.personUid)
|
|
199
|
+
continue;
|
|
200
|
+
for (const key of memberMatchKeys(member)) {
|
|
201
|
+
const bucket = byKey.get(key);
|
|
202
|
+
if (bucket)
|
|
203
|
+
bucket.push(member);
|
|
204
|
+
else
|
|
205
|
+
byKey.set(key, [member]);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const rosterNames = members
|
|
209
|
+
.map((m) => (m.displayName ?? "").trim() || m.personUid)
|
|
210
|
+
.filter(Boolean)
|
|
211
|
+
.sort((a, b) => a.localeCompare(b));
|
|
212
|
+
const mentions = [];
|
|
213
|
+
const claimed = new Set();
|
|
214
|
+
for (const token of tokens) {
|
|
215
|
+
const matches = byKey.get(token.trim().toLowerCase()) ?? [];
|
|
216
|
+
const unique = [...new Map(matches.map((m) => [m.personUid, m])).values()];
|
|
217
|
+
if (unique.length === 0) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
error: `No channel member matches '@${token}'. Members: ` +
|
|
221
|
+
`${rosterNames.join(", ") || "(none)"}. ` +
|
|
222
|
+
`Fix the name, or pass --no-mentions to post the text literally.`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
if (unique.length > 1) {
|
|
226
|
+
const candidates = unique
|
|
227
|
+
.map((m) => `${(m.displayName ?? "").trim() || m.personUid} (${m.personUid})`)
|
|
228
|
+
.join(", ");
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
error: `'@${token}' is ambiguous — it matches ${unique.length} members: ${candidates}. ` +
|
|
232
|
+
`Use the full name in quotes (e.g. @"Full Name"), or pass --no-mentions ` +
|
|
233
|
+
`to post the text literally.`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const member = unique[0];
|
|
237
|
+
if (claimed.has(member.personUid))
|
|
238
|
+
continue;
|
|
239
|
+
claimed.add(member.personUid);
|
|
240
|
+
mentions.push({
|
|
241
|
+
participantUid: member.personUid,
|
|
242
|
+
participantType: mentionParticipantType(member.personUid),
|
|
243
|
+
displayName: (member.displayName ?? "").trim() || member.personUid,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
if (mentions.length > MAX_CHANNEL_MENTIONS) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
error: `That message mentions ${mentions.length} members; the server accepts at most ` +
|
|
250
|
+
`${MAX_CHANNEL_MENTIONS}. Trim the mentions, or pass --no-mentions.`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return { ok: true, mentions };
|
|
254
|
+
}
|
|
104
255
|
/**
|
|
105
256
|
* Parse a relative duration like `30s`, `10m`, `2h`, `1d` into milliseconds.
|
|
106
257
|
* Returns null on anything that doesn't match. Pure → unit-testable.
|
|
@@ -336,6 +487,23 @@ async function fetchChannels(token) {
|
|
|
336
487
|
const data = (await res.json());
|
|
337
488
|
return data.channels ?? [];
|
|
338
489
|
}
|
|
490
|
+
/**
|
|
491
|
+
* Fetch a channel's roster (GET /v1/notify/channels/{id}/members). Any member
|
|
492
|
+
* who can read the channel may read its members, so this is the same gate the
|
|
493
|
+
* post itself passes. Used to resolve `@name` mentions into participant uids.
|
|
494
|
+
*/
|
|
495
|
+
async function fetchChannelMembers(token, channelId) {
|
|
496
|
+
const res = await vaultApiFetch({
|
|
497
|
+
token,
|
|
498
|
+
path: `/v1/notify/channels/${encodeURIComponent(channelId)}/members`,
|
|
499
|
+
});
|
|
500
|
+
if (!res.ok) {
|
|
501
|
+
const err = (await res.json().catch(() => ({})));
|
|
502
|
+
throw new Error(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText));
|
|
503
|
+
}
|
|
504
|
+
const data = (await res.json());
|
|
505
|
+
return data.members ?? [];
|
|
506
|
+
}
|
|
339
507
|
/**
|
|
340
508
|
* Channel DM path: `hq dm vyg-dev "msg"`, `hq dm '#vyg-dev' "msg"`, or
|
|
341
509
|
* `hq dm --channel vyg-dev "msg"`. Resolves the caller's channel by name via
|
|
@@ -373,18 +541,38 @@ async function runChannelSend(channelName, message, opts) {
|
|
|
373
541
|
process.exit(1);
|
|
374
542
|
}
|
|
375
543
|
const channel = matches[0];
|
|
544
|
+
// Mentions are structured-only server-side: resolve `@name` against the
|
|
545
|
+
// roster and send the array, or fail before posting. --no-mentions opts out
|
|
546
|
+
// and posts the text literally.
|
|
547
|
+
let mentions = [];
|
|
548
|
+
if (opts.mentions !== false) {
|
|
549
|
+
const tokens = parseMentionTokens(body);
|
|
550
|
+
if (tokens.length > 0) {
|
|
551
|
+
const members = await fetchChannelMembers(token, channel.channelId);
|
|
552
|
+
const resolved = resolveMentions(tokens, members);
|
|
553
|
+
if (!resolved.ok) {
|
|
554
|
+
console.error(chalk.red(resolved.error));
|
|
555
|
+
process.exit(1);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
mentions = resolved.mentions;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
376
561
|
const sendRes = await vaultApiFetch({
|
|
377
562
|
token,
|
|
378
563
|
path: `/v1/notify/channels/${encodeURIComponent(channel.channelId)}/messages`,
|
|
379
564
|
method: "POST",
|
|
380
|
-
body: { body },
|
|
565
|
+
body: mentions.length > 0 ? { body, mentions } : { body },
|
|
381
566
|
});
|
|
382
567
|
if (!sendRes.ok) {
|
|
383
568
|
const err = (await sendRes.json().catch(() => ({})));
|
|
384
569
|
console.error(chalk.red(friendlyDmError(sendRes.status, err.code, err.error ?? err.message ?? sendRes.statusText)));
|
|
385
570
|
process.exit(1);
|
|
386
571
|
}
|
|
387
|
-
|
|
572
|
+
const mentionSuffix = mentions.length > 0
|
|
573
|
+
? ` Mentioned ${mentions.map((m) => m.displayName).join(", ")}.`
|
|
574
|
+
: "";
|
|
575
|
+
console.log(chalk.green(`Message posted to #${channel.name ?? channelName}.${mentionSuffix}`));
|
|
388
576
|
}
|
|
389
577
|
catch (err) {
|
|
390
578
|
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
@@ -781,8 +969,9 @@ export function registerDmCommand(program) {
|
|
|
781
969
|
.description("Send and read direct messages, and manage connection requests.");
|
|
782
970
|
dm
|
|
783
971
|
.command("send [recipient] [message]", { isDefault: true, hidden: true })
|
|
784
|
-
.description('Send a direct message. RECIPIENT can be a person (email, personUid, or agentUid), a GROUP DM (comma-separated: "a@x.com,b@y.com"), or one of your DM CHANNELS by name — bare (hq dm vyg-dev "hi"), hash form (hq dm "#vyg-dev" "hi"), or via --channel (hq dm --channel vyg-dev "hi"). A person receives a DM as an HQ Sync notification; an agent receives it in its durable box inbox. If you aren\'t connected yet, it sends a connection request that holds your message. See your channels with `hq channels
|
|
972
|
+
.description('Send a direct message. RECIPIENT can be a person (email, personUid, or agentUid), a GROUP DM (comma-separated: "a@x.com,b@y.com"), or one of your DM CHANNELS by name — bare (hq dm vyg-dev "hi"), hash form (hq dm "#vyg-dev" "hi"), or via --channel (hq dm --channel vyg-dev "hi"). A person receives a DM as an HQ Sync notification; an agent receives it in its durable box inbox. If you aren\'t connected yet, it sends a connection request that holds your message. See your channels with `hq channels`.\n\nCHANNEL MENTIONS: HQ mentions are structured — the server never reads `@name` out of your text, so a channel message has to carry the participants alongside the body. When you write `@name` in a channel message, this command looks the name up in the channel roster (case-insensitive on display name and slug/handle; quote multi-word names as @"Jacob Posel") and sends it as a real mention, which notifies people and wakes agents. If a name is unknown or matches more than one member, the send FAILS with the candidates instead of posting something that mentions nobody. A bare "@" or an email address in your prose is never treated as a mention. Pass --no-mentions to post the text literally.')
|
|
785
973
|
.option("--channel <name>", "Post the message to one of your DM channels by name (e.g. --channel vyg-dev)")
|
|
974
|
+
.option("--no-mentions", "Do not resolve `@name` in a channel message into structured mentions — post the text literally")
|
|
786
975
|
.option("--prompt <text>", "Agent-context prompt the recipient can one-click copy into their agent (1:1 DMs only)")
|
|
787
976
|
.option("--prompt-file <path>", "Read the agent prompt from a file (1:1 DMs only)")
|
|
788
977
|
.option("--details <text>", "Longer detail shown in the recipient's DM detail window (1:1 DMs only)")
|
|
@@ -129,11 +129,36 @@ export interface AgentsV2AttestationOptions {
|
|
|
129
129
|
*/
|
|
130
130
|
export declare function isAgentsV2Runtime(hqRoot: string, env?: NodeJS.ProcessEnv): boolean;
|
|
131
131
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* `
|
|
132
|
+
* Env var overriding the agents-v2 runtime (hermes) config path (chiefly for
|
|
133
|
+
* tests), mirroring `HQ_RUNTIME_MARKER_FILE` and the shell's
|
|
134
|
+
* `HQ_HERMES_CONFIG_FILE`.
|
|
135
135
|
*/
|
|
136
|
-
export declare
|
|
136
|
+
export declare const HQ_HERMES_CONFIG_ENV = "HQ_HERMES_CONFIG_FILE";
|
|
137
|
+
/**
|
|
138
|
+
* The agents-v2 runtime (hermes) config whose `hooks:` block wires the on-box
|
|
139
|
+
* adapter into every lifecycle event — the env override, else `~/.hermes/config.yaml`
|
|
140
|
+
* (where `provision/render-config.sh` renders it). Mirrors the shell's
|
|
141
|
+
* `HQ_HERMES_CONFIG_FILE="${HQ_HERMES_CONFIG_FILE:-$HOME/.hermes/config.yaml}"`.
|
|
142
|
+
*/
|
|
143
|
+
export declare function resolveHermesConfigPath(env?: NodeJS.ProcessEnv): string;
|
|
144
|
+
/**
|
|
145
|
+
* Whether the agents-v2 runtime actually wires the on-box hook adapter.
|
|
146
|
+
*
|
|
147
|
+
* The wiring lives in the RUNTIME's hook config, not `.claude/settings.json`. On
|
|
148
|
+
* a real box the v2 runtime's shell-hook dispatcher (`agent/shell_hooks.py`)
|
|
149
|
+
* reads a `hooks:` block from `~/.hermes/config.yaml` with one entry per
|
|
150
|
+
* lifecycle event, each invoking `hq-agents-v2-hook-adapter.sh`; the adapter in
|
|
151
|
+
* turn READS `.claude/settings.json` (via `hook-adapter-core.sh`) to fan out to
|
|
152
|
+
* the classic `.claude/hooks` set. So `.claude/settings.json` never NAMES the
|
|
153
|
+
* adapter — it wires the classic `hook-gate.sh` hooks — and grepping it for the
|
|
154
|
+
* adapter always fails on a real box (verified on the v2.17 canary
|
|
155
|
+
* i-0277243ad3aed8109, 2026-09-05: settings.json had 0 adapter refs / 94
|
|
156
|
+
* hook-gate.sh refs, while ~/.hermes/config.yaml wired the adapter across 7
|
|
157
|
+
* events). Require BOTH the adapter installed under the tree at
|
|
158
|
+
* {@link AGENTS_V2_ADAPTER_RELPATH} AND the runtime config invoking it. Mirrors
|
|
159
|
+
* `hq_runtime_config_wires_v2_adapter` in `check-hq-hooks.sh`.
|
|
160
|
+
*/
|
|
161
|
+
export declare function runtimeConfigWiresV2Adapter(hqRoot: string, env?: NodeJS.ProcessEnv): boolean;
|
|
137
162
|
/**
|
|
138
163
|
* Whether a policy-trigger ledger evidencing a live agents-v2 turn is present:
|
|
139
164
|
* the exact session's ledger when a session id is given (session identity
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
* very evidence it is looking for.
|
|
39
39
|
*/
|
|
40
40
|
import * as fs from "node:fs";
|
|
41
|
+
import * as os from "node:os";
|
|
41
42
|
import * as path from "node:path";
|
|
42
43
|
import { scanHookCommand } from "./claude-wiring.js";
|
|
43
44
|
/** Common id prefix for every result the runtime probe emits. */
|
|
@@ -160,14 +161,47 @@ function readRuntimeMarkerMode(markerPath) {
|
|
|
160
161
|
}
|
|
161
162
|
}
|
|
162
163
|
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* `
|
|
164
|
+
* Env var overriding the agents-v2 runtime (hermes) config path (chiefly for
|
|
165
|
+
* tests), mirroring `HQ_RUNTIME_MARKER_FILE` and the shell's
|
|
166
|
+
* `HQ_HERMES_CONFIG_FILE`.
|
|
166
167
|
*/
|
|
167
|
-
export
|
|
168
|
+
export const HQ_HERMES_CONFIG_ENV = "HQ_HERMES_CONFIG_FILE";
|
|
169
|
+
/**
|
|
170
|
+
* The agents-v2 runtime (hermes) config whose `hooks:` block wires the on-box
|
|
171
|
+
* adapter into every lifecycle event — the env override, else `~/.hermes/config.yaml`
|
|
172
|
+
* (where `provision/render-config.sh` renders it). Mirrors the shell's
|
|
173
|
+
* `HQ_HERMES_CONFIG_FILE="${HQ_HERMES_CONFIG_FILE:-$HOME/.hermes/config.yaml}"`.
|
|
174
|
+
*/
|
|
175
|
+
export function resolveHermesConfigPath(env = process.env) {
|
|
176
|
+
const override = env[HQ_HERMES_CONFIG_ENV]?.trim();
|
|
177
|
+
if (override)
|
|
178
|
+
return override;
|
|
179
|
+
return path.join(os.homedir(), ".hermes", "config.yaml");
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Whether the agents-v2 runtime actually wires the on-box hook adapter.
|
|
183
|
+
*
|
|
184
|
+
* The wiring lives in the RUNTIME's hook config, not `.claude/settings.json`. On
|
|
185
|
+
* a real box the v2 runtime's shell-hook dispatcher (`agent/shell_hooks.py`)
|
|
186
|
+
* reads a `hooks:` block from `~/.hermes/config.yaml` with one entry per
|
|
187
|
+
* lifecycle event, each invoking `hq-agents-v2-hook-adapter.sh`; the adapter in
|
|
188
|
+
* turn READS `.claude/settings.json` (via `hook-adapter-core.sh`) to fan out to
|
|
189
|
+
* the classic `.claude/hooks` set. So `.claude/settings.json` never NAMES the
|
|
190
|
+
* adapter — it wires the classic `hook-gate.sh` hooks — and grepping it for the
|
|
191
|
+
* adapter always fails on a real box (verified on the v2.17 canary
|
|
192
|
+
* i-0277243ad3aed8109, 2026-09-05: settings.json had 0 adapter refs / 94
|
|
193
|
+
* hook-gate.sh refs, while ~/.hermes/config.yaml wired the adapter across 7
|
|
194
|
+
* events). Require BOTH the adapter installed under the tree at
|
|
195
|
+
* {@link AGENTS_V2_ADAPTER_RELPATH} AND the runtime config invoking it. Mirrors
|
|
196
|
+
* `hq_runtime_config_wires_v2_adapter` in `check-hq-hooks.sh`.
|
|
197
|
+
*/
|
|
198
|
+
export function runtimeConfigWiresV2Adapter(hqRoot, env = process.env) {
|
|
199
|
+
const adapter = path.join(hqRoot, ...AGENTS_V2_ADAPTER_RELPATH.split("/"));
|
|
200
|
+
if (!isFile(adapter))
|
|
201
|
+
return false;
|
|
168
202
|
let raw;
|
|
169
203
|
try {
|
|
170
|
-
raw = fs.readFileSync(
|
|
204
|
+
raw = fs.readFileSync(resolveHermesConfigPath(env), "utf8");
|
|
171
205
|
}
|
|
172
206
|
catch {
|
|
173
207
|
return false;
|
|
@@ -237,7 +271,7 @@ function ledgerDirHasFreshTxt(dir, cutoffMs) {
|
|
|
237
271
|
export function agentsV2Attested(opts) {
|
|
238
272
|
const env = opts.env ?? process.env;
|
|
239
273
|
return (isAgentsV2Runtime(opts.hqRoot, env) &&
|
|
240
|
-
|
|
274
|
+
runtimeConfigWiresV2Adapter(opts.hqRoot, env) &&
|
|
241
275
|
v2LedgerPresent({ ...opts, env }));
|
|
242
276
|
}
|
|
243
277
|
/**
|
|
@@ -256,7 +290,7 @@ export function checkRuntimeProbe(context) {
|
|
|
256
290
|
// platform-unknown and, on an unknown host, the probe would report UNKNOWN
|
|
257
291
|
// below. But the on-box adapter provably wrote the ledger through the same
|
|
258
292
|
// .claude hooks, so grant PASS — and report platform "agents-v2" — when, and
|
|
259
|
-
// only when, the runtime is agents-v2,
|
|
293
|
+
// only when, the runtime is agents-v2, the runtime config wires the on-box adapter, and a
|
|
260
294
|
// ledger exists (the exact session's under --session-id; otherwise any ledger
|
|
261
295
|
// fresh within the window). Requires the on-box marker/adapter, so this never
|
|
262
296
|
// changes the verdict for any other host. See agentsV2Attested().
|
|
@@ -128,8 +128,17 @@ export interface ClientHealthHeartbeat {
|
|
|
128
128
|
syncState: ClientHealthSyncState;
|
|
129
129
|
/** Last time a sync RUN started — distinct from success. */
|
|
130
130
|
lastSyncAttemptAt?: string;
|
|
131
|
-
/** Advances only on genuine
|
|
131
|
+
/** Advances only on a genuine COMPLETED run (including no-change runs). Never the engine watermark. */
|
|
132
132
|
lastSyncSuccessAt?: string;
|
|
133
|
+
/**
|
|
134
|
+
* Sync-engine per-file journal high-water mark (hq-cloud `journal.lastSync`).
|
|
135
|
+
* Advances whenever ANY file moves — including on a run that later FAILS —
|
|
136
|
+
* so it is NOT proof a run completed and must never be read as a completed
|
|
137
|
+
* success. Distinct from `lastSyncSuccessAt`, and never folded into it.
|
|
138
|
+
* Absent on clients with no local sync engine (desktop) and on clients that
|
|
139
|
+
* predate this field — absence means "unknown", not "no activity".
|
|
140
|
+
*/
|
|
141
|
+
syncEngineWatermarkAt?: string;
|
|
133
142
|
consecutiveFailures: number;
|
|
134
143
|
conflictCount?: number;
|
|
135
144
|
updaterState?: ClientHealthUpdaterState;
|
|
@@ -253,6 +253,8 @@ export function parseClientHealthHeartbeat(input) {
|
|
|
253
253
|
heartbeat.lastSyncAttemptAt = assertIsoUtc("lastSyncAttemptAt", raw.lastSyncAttemptAt);
|
|
254
254
|
if (raw.lastSyncSuccessAt !== undefined)
|
|
255
255
|
heartbeat.lastSyncSuccessAt = assertIsoUtc("lastSyncSuccessAt", raw.lastSyncSuccessAt);
|
|
256
|
+
if (raw.syncEngineWatermarkAt !== undefined)
|
|
257
|
+
heartbeat.syncEngineWatermarkAt = assertIsoUtc("syncEngineWatermarkAt", raw.syncEngineWatermarkAt);
|
|
256
258
|
if (raw.conflictCount !== undefined) {
|
|
257
259
|
heartbeat.conflictCount = assertBoundedInt("conflictCount", raw.conflictCount, CLIENT_HEALTH_MAX_CONFLICT_COUNT);
|
|
258
260
|
}
|
|
@@ -335,7 +335,13 @@ export function buildCliHeartbeat(input) {
|
|
|
335
335
|
if (input.versions.sync && SEMVER.test(input.versions.sync)) {
|
|
336
336
|
versions.desktop = input.versions.sync;
|
|
337
337
|
}
|
|
338
|
-
|
|
338
|
+
// `lastSyncSuccessAt` is ONLY this CLI's own completed-run success — never
|
|
339
|
+
// the engine journal watermark. The watermark is carried on its own field
|
|
340
|
+
// (`syncEngineWatermarkAt`) so support can tell a completed run from mere
|
|
341
|
+
// per-file engine movement; folding the two would fabricate a completed-run
|
|
342
|
+
// success out of a run that may have failed (US-019).
|
|
343
|
+
const lastSyncSuccessAt = input.state.lastSyncSuccessAt ?? null;
|
|
344
|
+
const syncEngineWatermarkAt = input.journalLastSyncAt;
|
|
339
345
|
let syncState;
|
|
340
346
|
switch (input.kind) {
|
|
341
347
|
case "sync_attempt":
|
|
@@ -351,17 +357,20 @@ export function buildCliHeartbeat(input) {
|
|
|
351
357
|
// overwrote the `error` state the failing sync had reported while
|
|
352
358
|
// leaving the streak that earned it untouched.
|
|
353
359
|
//
|
|
354
|
-
// Only this CLI's own `sync_success` clears the streak. The
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
360
|
+
// Only this CLI's own `sync_success` clears the streak. The engine
|
|
361
|
+
// journal watermark (`syncEngineWatermarkAt`) deliberately does NOT: the
|
|
362
|
+
// engine stamps it per FILE update (hq-cloud journal.ts `updateEntry`),
|
|
363
|
+
// and a push stamps it before throwing its upload worker errors — so it
|
|
364
|
+
// means "some file moved", not "a run succeeded", and must never clear an
|
|
365
|
+
// alarm counter. `never_synced` still keys off ANY observed sync activity
|
|
366
|
+
// (a completed run OR engine movement); it only means this installation
|
|
367
|
+
// has never moved a byte.
|
|
361
368
|
if (input.state.consecutiveFailures > 0)
|
|
362
369
|
syncState = "error";
|
|
363
|
-
else
|
|
364
|
-
|
|
370
|
+
else {
|
|
371
|
+
const hasSyncedSomething = lastSyncSuccessAt !== null || syncEngineWatermarkAt !== null;
|
|
372
|
+
syncState = hasSyncedSomething ? "idle" : "never_synced";
|
|
373
|
+
}
|
|
365
374
|
break;
|
|
366
375
|
}
|
|
367
376
|
const heartbeat = {
|
|
@@ -382,20 +391,15 @@ export function buildCliHeartbeat(input) {
|
|
|
382
391
|
if (lastSyncSuccessAt !== null) {
|
|
383
392
|
heartbeat.lastSyncSuccessAt = lastSyncSuccessAt;
|
|
384
393
|
}
|
|
394
|
+
if (syncEngineWatermarkAt !== null) {
|
|
395
|
+
heartbeat.syncEngineWatermarkAt = syncEngineWatermarkAt;
|
|
396
|
+
}
|
|
385
397
|
if ((input.kind === "sync_success" || input.kind === "sync_failure") &&
|
|
386
398
|
input.localFilesOverview) {
|
|
387
399
|
heartbeat.localFilesOverview = input.localFilesOverview;
|
|
388
400
|
}
|
|
389
401
|
return heartbeat;
|
|
390
402
|
}
|
|
391
|
-
function latestIso(a, b) {
|
|
392
|
-
const times = [a, b]
|
|
393
|
-
.map((value) => (typeof value === "string" ? Date.parse(value) : NaN))
|
|
394
|
-
.filter((value) => Number.isFinite(value));
|
|
395
|
-
if (times.length === 0)
|
|
396
|
-
return null;
|
|
397
|
-
return new Date(Math.max(...times)).toISOString();
|
|
398
|
-
}
|
|
399
403
|
const defaultPoster = (heartbeat, token, signal) => vaultApiFetch({
|
|
400
404
|
token,
|
|
401
405
|
path: "/v1/client-health/heartbeat",
|
|
@@ -60,7 +60,7 @@ import { spawnSync } from "node:child_process";
|
|
|
60
60
|
import semver from "semver";
|
|
61
61
|
import chalk from "chalk";
|
|
62
62
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
63
|
-
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, inOwnProcessGroup, isLocalDependencyInstall, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
|
|
63
|
+
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, inOwnProcessGroup, isLocalDependencyInstall, isPrefixWritable, nonWritablePrefixNote, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
|
|
64
64
|
import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
|
|
65
65
|
import { markLatestIneffective } from "./version-check.js";
|
|
66
66
|
/**
|
|
@@ -268,6 +268,13 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
268
268
|
console.error(chalk.yellow(`⚠ hq-cli ${latest} is available but the update failed` +
|
|
269
269
|
`${result.detail ? `: ${result.detail}` : ""}`));
|
|
270
270
|
console.error(chalk.dim(` Try manually: ${plan.cmd} ${plan.args.join(" ")}`));
|
|
271
|
+
// A root-owned npm prefix the running user cannot write is the agent-box
|
|
272
|
+
// case (the CLI is a /usr global install and the runtime is unprivileged),
|
|
273
|
+
// and the startup path has no sudo fallback — say so plainly so a failed
|
|
274
|
+
// update on a box reads as the permission wall it is, not a transient error.
|
|
275
|
+
if (install.manager === "npm" && install.prefix && !isPrefixWritable(install.prefix)) {
|
|
276
|
+
console.error(chalk.yellow(` ${nonWritablePrefixNote(install.prefix)}`));
|
|
277
|
+
}
|
|
271
278
|
// A manager-level failure often means the install layout itself is broken
|
|
272
279
|
// (e.g. a hand-rolled pnpm store nested inside the app's bin dir). The
|
|
273
280
|
// manual retry above hits the same layout and fails the same way; point at
|
|
@@ -150,6 +150,30 @@ export declare function resolveRunningInstall(): RunningInstall;
|
|
|
150
150
|
export declare function resolveRunningManager(): InstallManager;
|
|
151
151
|
/** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
|
|
152
152
|
export declare function resolveRunningPrefix(): string | null;
|
|
153
|
+
/**
|
|
154
|
+
* Whether the current process can write the npm global `prefix` — i.e. whether
|
|
155
|
+
* an `npm install -g --prefix <prefix>` could actually replace the installed
|
|
156
|
+
* CLI, or would fail with EACCES.
|
|
157
|
+
*
|
|
158
|
+
* The path npm rewrites is the prefix's `bin` dir (`<prefix>/bin/hq` on unix
|
|
159
|
+
* globals — the `rename /usr/bin/hq` EACCES the agent boxes hit), so that is
|
|
160
|
+
* checked first; the prefix itself is the fallback for `--prefix` layouts that
|
|
161
|
+
* keep the bin beside `node_modules`. A missing dir (ENOENT) is treated as
|
|
162
|
+
* writable: npm would create it, and this check exists to explain a permission
|
|
163
|
+
* wall, not to second-guess a not-yet-created prefix.
|
|
164
|
+
*
|
|
165
|
+
* `access` is injected so the classification is unit-testable without a real
|
|
166
|
+
* root-owned prefix.
|
|
167
|
+
*/
|
|
168
|
+
export declare function isPrefixWritable(prefix: string, access?: (target: string, mode: number) => void): boolean;
|
|
169
|
+
/**
|
|
170
|
+
* One-line operator explanation for a failed global update whose prefix the
|
|
171
|
+
* running user cannot write. This is the agent-box case: the CLI is a
|
|
172
|
+
* root-owned `/usr` global install and the runtime is unprivileged, so the
|
|
173
|
+
* update genuinely cannot converge from here and re-trying it silently would
|
|
174
|
+
* loop. Says so plainly and points at the paths that CAN update it.
|
|
175
|
+
*/
|
|
176
|
+
export declare function nonWritablePrefixNote(prefix: string): string;
|
|
153
177
|
/**
|
|
154
178
|
* Derive `PNPM_HOME` from a pnpm-managed install's own path. pnpm resolves its
|
|
155
179
|
* global bin directory from `PNPM_HOME` (or an explicit `global-bin-dir`), and
|
|
@@ -409,6 +433,8 @@ export declare const __test__: {
|
|
|
409
433
|
isNewerVersion: typeof isNewerVersion;
|
|
410
434
|
isPnpmManagedPackageDir: typeof isPnpmManagedPackageDir;
|
|
411
435
|
isPnpmVirtualStorePackageDir: typeof isPnpmVirtualStorePackageDir;
|
|
436
|
+
isPrefixWritable: typeof isPrefixWritable;
|
|
437
|
+
nonWritablePrefixNote: typeof nonWritablePrefixNote;
|
|
412
438
|
npmPrefixFromPackageDir: typeof npmPrefixFromPackageDir;
|
|
413
439
|
nudgeUpdateRecommended: typeof nudgeUpdateRecommended;
|
|
414
440
|
performUpdate: typeof performUpdate;
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { spawnSync } from "node:child_process";
|
|
31
31
|
import { buildSpawnPlan, quoteForWindowsShell } from "./windows-spawn.js";
|
|
32
|
-
import { closeSync, existsSync, mkdtempSync, openSync, readdirSync, readFileSync, rmSync, } from "node:fs";
|
|
32
|
+
import { accessSync, closeSync, constants as fsConstants, existsSync, mkdtempSync, openSync, readdirSync, readFileSync, rmSync, } from "node:fs";
|
|
33
33
|
import os from "node:os";
|
|
34
34
|
import path from "node:path";
|
|
35
35
|
import { fileURLToPath } from "node:url";
|
|
@@ -277,6 +277,50 @@ export function resolveRunningManager() {
|
|
|
277
277
|
export function resolveRunningPrefix() {
|
|
278
278
|
return resolveRunningInstall().prefix;
|
|
279
279
|
}
|
|
280
|
+
/**
|
|
281
|
+
* Whether the current process can write the npm global `prefix` — i.e. whether
|
|
282
|
+
* an `npm install -g --prefix <prefix>` could actually replace the installed
|
|
283
|
+
* CLI, or would fail with EACCES.
|
|
284
|
+
*
|
|
285
|
+
* The path npm rewrites is the prefix's `bin` dir (`<prefix>/bin/hq` on unix
|
|
286
|
+
* globals — the `rename /usr/bin/hq` EACCES the agent boxes hit), so that is
|
|
287
|
+
* checked first; the prefix itself is the fallback for `--prefix` layouts that
|
|
288
|
+
* keep the bin beside `node_modules`. A missing dir (ENOENT) is treated as
|
|
289
|
+
* writable: npm would create it, and this check exists to explain a permission
|
|
290
|
+
* wall, not to second-guess a not-yet-created prefix.
|
|
291
|
+
*
|
|
292
|
+
* `access` is injected so the classification is unit-testable without a real
|
|
293
|
+
* root-owned prefix.
|
|
294
|
+
*/
|
|
295
|
+
export function isPrefixWritable(prefix, access = (target, mode) => accessSync(target, mode)) {
|
|
296
|
+
for (const dir of [path.join(prefix, "bin"), prefix]) {
|
|
297
|
+
try {
|
|
298
|
+
access(dir, fsConstants.W_OK);
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
// A dir that does not exist yet is not a permission wall — npm creates it.
|
|
303
|
+
if (err?.code === "ENOENT") {
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
// Any other error (EACCES/EPERM/EROFS) on this candidate: try the next.
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* One-line operator explanation for a failed global update whose prefix the
|
|
313
|
+
* running user cannot write. This is the agent-box case: the CLI is a
|
|
314
|
+
* root-owned `/usr` global install and the runtime is unprivileged, so the
|
|
315
|
+
* update genuinely cannot converge from here and re-trying it silently would
|
|
316
|
+
* loop. Says so plainly and points at the paths that CAN update it.
|
|
317
|
+
*/
|
|
318
|
+
export function nonWritablePrefixNote(prefix) {
|
|
319
|
+
return (`The npm global prefix ${prefix} is not writable by the current user, so this update cannot ` +
|
|
320
|
+
`take effect here. On an agent box this is expected: the CLI is a root-owned global install and ` +
|
|
321
|
+
`updates land as root — via the box's hq-cli-update timer, or \`sudo npm install -g ${CLI_NAME}@latest\` — ` +
|
|
322
|
+
`not from the unprivileged runtime.`);
|
|
323
|
+
}
|
|
280
324
|
/**
|
|
281
325
|
* Derive `PNPM_HOME` from a pnpm-managed install's own path. pnpm resolves its
|
|
282
326
|
* global bin directory from `PNPM_HOME` (or an explicit `global-bin-dir`), and
|
|
@@ -907,6 +951,13 @@ function attemptRequiredUpdate(decision, deps, install) {
|
|
|
907
951
|
}
|
|
908
952
|
if (!result.ok) {
|
|
909
953
|
console.error(chalk.red(`✗ Update failed${result.detail ? `: ${result.detail}` : ""}.`));
|
|
954
|
+
// A root-owned global prefix the running user cannot write is the agent-box
|
|
955
|
+
// case: even the `sudo -n` retry above cannot help without passwordless
|
|
956
|
+
// sudo, so name the wall plainly rather than leaving `exit 75` to read as a
|
|
957
|
+
// generic failure. Only for npm-prefix installs (pnpm/Bun route elsewhere).
|
|
958
|
+
if (!isManagedOutsideNpm && prefix && !isPrefixWritable(prefix)) {
|
|
959
|
+
console.error(chalk.yellow(` ${nonWritablePrefixNote(prefix)}`));
|
|
960
|
+
}
|
|
910
961
|
// The package manager itself is missing from this environment — the usual
|
|
911
962
|
// cause is a minimal-PATH parent (launchd, cron, a bare systemd unit) that
|
|
912
963
|
// never sourced the shell profile which puts PNPM_HOME (or nvm's npm) on
|
|
@@ -996,6 +1047,8 @@ export const __test__ = {
|
|
|
996
1047
|
isNewerVersion,
|
|
997
1048
|
isPnpmManagedPackageDir,
|
|
998
1049
|
isPnpmVirtualStorePackageDir,
|
|
1050
|
+
isPrefixWritable,
|
|
1051
|
+
nonWritablePrefixNote,
|
|
999
1052
|
npmPrefixFromPackageDir,
|
|
1000
1053
|
nudgeUpdateRecommended,
|
|
1001
1054
|
performUpdate,
|