hivecode-mcp 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/HIVE_RULES.md +98 -98
  2. package/LICENSE +21 -21
  3. package/README.md +421 -421
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -310
  6. package/docs/GITHUB_WORKFLOW.md +394 -394
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -157
  9. package/docs/QUICKSTART.md +266 -266
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +474 -450
  12. package/docs/TROUBLESHOOTING.md +105 -105
  13. package/hive-activity.js +336 -336
  14. package/hive-authors.js +46 -44
  15. package/hive-await.js +230 -230
  16. package/hive-branch.js +608 -607
  17. package/hive-busy.js +88 -88
  18. package/hive-coord.js +110 -110
  19. package/hive-gate.js +135 -135
  20. package/hive-github.js +2004 -1944
  21. package/hive-hook-install.js +179 -179
  22. package/hive-instructions.js +74 -74
  23. package/hive-land.js +695 -691
  24. package/hive-listen.js +81 -81
  25. package/hive-mailbox.js +520 -520
  26. package/hive-mcp-http.js +591 -580
  27. package/hive-mcp.js +2156 -2093
  28. package/hive-mux-proto.js +58 -58
  29. package/hive-mux.js +351 -350
  30. package/hive-notify.js +234 -233
  31. package/hive-obligations.js +247 -247
  32. package/hive-poll-proto.js +50 -50
  33. package/hive-pr-staleness.js +189 -189
  34. package/hive-pr.js +600 -512
  35. package/hive-privacy.js +327 -327
  36. package/hive-receipts.js +233 -233
  37. package/hive-relay-health.js +234 -230
  38. package/hive-repos.js +430 -430
  39. package/hive-room-key.js +210 -209
  40. package/hive-room-mode.js +1668 -1668
  41. package/hive-room-watcher.js +562 -561
  42. package/hive-secrets.js +89 -89
  43. package/hive-session.js +114 -114
  44. package/hive-setup.js +560 -558
  45. package/hive-slack-agent.js +408 -396
  46. package/hive-slack.js +2384 -1809
  47. package/hive-stdio.js +172 -172
  48. package/hive-stop-hook.js +293 -293
  49. package/hive-throttle.js +118 -118
  50. package/hive-time.js +71 -71
  51. package/hive-transport.js +578 -353
  52. package/hive-update.js +58 -58
  53. package/hive-version.js +110 -110
  54. package/hive-webhook.js +55 -54
  55. package/icr-merge.js +58 -58
  56. package/icr.js +851 -851
  57. package/lang-brace.js +305 -305
  58. package/lang-js.js +651 -651
  59. package/lang-json.js +211 -211
  60. package/lang-python.js +588 -588
  61. package/lang-ruby.js +230 -230
  62. package/lang-toml.js +136 -136
  63. package/lang-yaml.js +183 -183
  64. package/limits.js +413 -413
  65. package/package.json +192 -192
  66. package/reconcile.cjs +621 -614
  67. package/slack-app-manifest-agent.json +61 -61
  68. package/slack-app-manifest-inbound.json +36 -36
  69. package/slack-app-manifest.json +27 -27
  70. package/substrate.js +252 -252
  71. package/sync.js +5189 -4994
  72. package/token.js +201 -201
@@ -1,230 +1,234 @@
1
- // WHY FILES ARE NOT SYNCING — asked of the relay, not guessed from the symptom.
2
- //
3
- // THE DEFECT THIS REPLACES.
4
- // Two places told an operator why their files were not arriving: sync.js, when file docs went
5
- // quiet, and hive-mcp.js, when hive_join failed to sync. Both carried the same three faults.
6
- //
7
- // 1. Both printed the literal string "<relay-http>/health" -- a placeholder, never
8
- // substituted, while the relay URL sat in scope a few lines away.
9
- // 2. Both named only Q430 and only limits.maxConnsTotal. limits.js:176 ALSO refuses with
10
- // Q431 on a PER-ROOM cap, and the two need different fixes: HIVE_MAX_CONNS_TOTAL for the
11
- // first, HIVE_SEATS for the second. An operator hitting Q431 and following that advice
12
- // raises a limit that was never the constraint, sees no change, and is entitled to
13
- // conclude the product is broken.
14
- // 3. Both told a HUMAN to go read /health. The client can read it itself, in the same
15
- // breath, and say which cause it actually is.
16
- //
17
- // MEASURED, 21 Sep 2026. A third agent joined room `night` and announced that 49 files could
18
- // not connect, "the usual cause is the relay being full (Q430)". At that moment the relay
19
- // reported 56 connections of 512. limits.js keys its per-room counter on the FILE-room, and
20
- // each file held three connections against a cap of 64. Q430 needed 512 and had 56. Q431 needed
21
- // 64 and had 3. NEITHER REFUSAL COULD HAVE FIRED.
22
- //
23
- // What had actually happened: /health went rooms 44 -> 0 -> 29 -> 31 -> 44 over a few minutes.
24
- // The relay dropped its room state and every client began reconnecting, one socket per file.
25
- //
26
- // "The relay is full" and "the relay just dropped everyone and we are all reconnecting" produce
27
- // an IDENTICAL local symptom -- N file docs with synced=false -- and need opposite responses: a
28
- // config change, or thirty seconds of patience. The difference is a number only the relay has.
29
- // So ask it.
30
- //
31
- // ONE PRODUCER, ON PURPOSE. learnings/repair-the-class-not-the-copy.md exists in this repo
32
- // because a fix landed on one instance and the class survived into two more tickets. Two
33
- // hand-written copies of a diagnosis is that state exactly: they had already drifted (the
34
- // hive-mcp.js copy listed three candidate causes, the sync.js copy asserted one). This module is
35
- // the only place the wording lives, and tests/relay-diagnosis-test.mjs COUNTS the producers so a
36
- // third copy fails a test rather than shipping.
37
-
38
- /** A free-tier per-room cap. At or below this, Q431 is the likely refusal and seats are the fix. */
39
- export const FREE_TIER_ROOM_CAP = 8
40
-
41
- /** Fraction of maxConnsTotal at which "the relay is effectively full" is the right reading. */
42
- export const NEAR_CAPACITY = 0.9
43
-
44
- /**
45
- * Turn a websocket relay URL into its /health URL.
46
- * Exported because a caller that cannot fetch still wants to NAME the URL correctly, which is
47
- * the whole of defect (1) above.
48
- */
49
- export function healthUrlFor(relay) {
50
- const base = String(relay || '')
51
- .replace(/^wss:/i, 'https:')
52
- .replace(/^ws:/i, 'http:')
53
- .replace(/\/+$/, '')
54
- return /^https?:/i.test(base) ? `${base}/health` : null
55
- }
56
-
57
- /**
58
- * Ask the relay for its own numbers.
59
- *
60
- * NEVER THROWS, AND NEVER BLOCKS FOR LONG. This runs inside a diagnostic, and a diagnostic that
61
- * can hang is a diagnostic that withholds the warning it exists to deliver. The warning matters
62
- * more than the detail: a failed probe still produces a message, with the failure named.
63
- *
64
- * `ok` means "the probe worked", NOT "the relay is healthy". Conflating those is how a monitor
65
- * reports the absence of an answer as a clean bill of health.
66
- */
67
- export async function probeRelayHealth(relay, { timeoutMs = 2500, fetchImpl } = {}) {
68
- const url = healthUrlFor(relay)
69
- if (!url) return { ok: false, url: null, error: 'no usable relay URL' }
70
- const f = fetchImpl || (typeof fetch === 'function' ? fetch : null)
71
- if (!f) return { ok: false, url, error: 'no fetch available in this runtime' }
72
- try {
73
- const ac = new AbortController()
74
- const t = setTimeout(() => ac.abort(), timeoutMs)
75
- if (t && t.unref) t.unref()
76
- let res
77
- try { res = await f(url, { signal: ac.signal }) } finally { clearTimeout(t) }
78
- if (!res || !res.ok) return { ok: false, url, error: `HTTP ${res ? res.status : 'no response'}` }
79
- const j = await res.json()
80
- return { ok: true, url, health: j }
81
- } catch (e) {
82
- const name = e && e.name
83
- const why = name === 'AbortError' ? `no response in ${timeoutMs}ms` : ((e && e.message) || 'unreachable')
84
- return { ok: false, url, error: why }
85
- }
86
- }
87
-
88
- /** The four readings this can produce. Named so a test can assert on the verdict, not the prose. */
89
- export const RELAY_FULL = 'RELAY_FULL'
90
- export const ROOM_CAP = 'ROOM_CAP'
91
- export const NOT_CAPACITY = 'NOT_CAPACITY'
92
- export const UNREACHABLE = 'UNREACHABLE'
93
-
94
- /**
95
- * Decide what the numbers mean, and say it in a way an operator can act on.
96
- *
97
- * Returns { verdict, text }. The verdict is for tests and for callers that want to branch; the
98
- * text is what a human reads. Keeping them separate is what lets the test pin the DECISION
99
- * rather than the wording, so rephrasing the advice does not break the suite and changing the
100
- * advice does.
101
- *
102
- * @param {object} probe the result of probeRelayHealth()
103
- * @param {object} opts
104
- * @param {number} opts.stuck how many files are not synced
105
- * @param {string} opts.what 'files' (sync.js) or 'join' (hive-mcp.js) -- changes the lead only
106
- */
107
- export function diagnose(probe, { stuck = 0, what = 'files' } = {}) {
108
- const h = (probe && probe.ok && probe.health) || null
109
- const url = (probe && probe.url) || 'the relay'
110
-
111
- const lead = what === 'join'
112
- ? 'The relay did not sync this folder.'
113
- : `${stuck} file(s) are not synced and will READ AS EMPTY until this clears.`
114
-
115
- if (!h) {
116
- // A probe that failed is MORE specific than any guess, not less: the relay is unreachable
117
- // over HTTP from this machine, so capacity is not the question and no limit needs changing.
118
- return {
119
- verdict: UNREACHABLE,
120
- text: [
121
- lead,
122
- `I could not reach ${url}${probe && probe.error ? ` (${probe.error})` : ''}, so the relay may be down,`,
123
- `restarting, or blocked from this machine. THAT IS NOT A CAPACITY PROBLEM and no limit needs raising.`,
124
- `Check that the relay is running and that this host can reach it.`,
125
- ].join(' '),
126
- }
127
- }
128
-
129
- const total = Number(h.limits && h.limits.maxConnsTotal) || 0
130
- const conns = Number(h.connections) || 0
131
- const perRoom = Number(h.connsPerRoom) || Number(h.limits && h.limits.maxConnsPerRoom) || 0
132
- const seats = h.seats ? ` (${h.seats} seats)` : ''
133
- const facts = `I asked ${url}: ${conns} of ${total} connections in use, ${h.rooms || 0} rooms, per-room cap ${perRoom}${seats}.`
134
-
135
- // ONE SOCKET PER PARTICIPANT has its own ceiling: the files open across every participant's
136
- // shared socket (Q438). Checked first, because at that ceiling the socket count looks healthy.
137
- const chanCap = Number(h.limits && h.limits.maxChannelsTotal) || 0
138
- const chans = Number(h.channels) || 0
139
- if (Number(h.mux) >= 1 && chanCap && chans >= chanCap * NEAR_CAPACITY) {
140
- return {
141
- verdict: RELAY_FULL,
142
- text: [lead, `I asked ${url}: ${chans} of ${chanCap} open files in use across all participants.`,
143
- `THE RELAY IS AT ITS OPEN-FILE CAPACITY — this is Q438. Raise HIVE_MAX_CHANNELS_TOTAL on the relay,`,
144
- `or narrow what syncs.`].join(' '),
145
- }
146
- }
147
-
148
- if (total && conns >= total * NEAR_CAPACITY) {
149
- // Who spends the sockets depends on the transport. A relay offering one-socket mode charges
150
- // those clients 2 each; the older transport, still spoken by the VS Code extension, the browser
151
- // panel and HIVE_MUX=0, charges one PER FILE -- and that is what exhausts a relay.
152
- const who = Number(h.mux) >= 1
153
- ? `This relay carries each participant's files on one socket (2 sockets a participant), but clients`
154
- + ` on the older transport -- the VS Code extension, the browser panel, HIVE_MUX=0 -- still open one`
155
- + ` socket PER FILE, and one large folder on those can exhaust it alone.`
156
- : `Hivecode opens one socket PER FILE, so a single large folder can exhaust it alone.`
157
- return {
158
- verdict: RELAY_FULL,
159
- text: [lead, facts,
160
- `THE RELAY IS AT OR NEAR CAPACITY — this is Q430.`, who,
161
- `Raise HIVE_MAX_CONNS_TOTAL on the relay, or narrow what syncs.`].join(' '),
162
- }
163
- }
164
-
165
- if (perRoom && perRoom <= FREE_TIER_ROOM_CAP) {
166
- // The case the old message could not express at all, and the one where its advice was
167
- // actively harmful: total headroom is fine, so raising the total changes nothing.
168
- return {
169
- verdict: ROOM_CAP,
170
- text: [lead, facts,
171
- `The relay has total headroom, but its PER-ROOM cap is ${perRoom} and it refuses past that with Q431.`,
172
- `That is a free-tier default. Set HIVE_SEATS on the relay — raising HIVE_MAX_CONNS_TOTAL will NOT help,`,
173
- `because the total is not the constraint.`].join(' '),
174
- }
175
- }
176
-
177
- return {
178
- verdict: NOT_CAPACITY,
179
- text: [lead, facts,
180
- `CAPACITY IS NOT THE CAUSE: ${Math.max(0, total - conns)} of ${total} connections are free and the per-room`,
181
- `cap is ${perRoom}. These sockets are most likely still reconnecting — the relay drops room state on`,
182
- `restart and every file reconnects separately, which looks exactly like this for a minute or two.`,
183
- `Wait and re-check before changing any limit. If it persists, the room may be secured and this client`,
184
- `may carry no token, or the relay may be failing its licence check closed.`].join(' '),
185
- }
186
- }
187
-
188
- /**
189
- * The whole diagnosis in one call, for the two callers that want it.
190
- * `trailer` is appended verbatim -- sync.js needs "do not trust this folder's contents", a
191
- * failed join needs its own instructions, and neither belongs in the capacity reasoning.
192
- */
193
- export async function explainNotSyncing(relay, { stuck = 0, what = 'files', trailer = '', timeoutMs = 2500, fetchImpl } = {}) {
194
- const probe = await probeRelayHealth(relay, { timeoutMs, fetchImpl })
195
- const d = diagnose(probe, { stuck, what })
196
- return { ...d, probe, text: trailer ? `${d.text} ${trailer}` : d.text }
197
- }
198
-
199
- /**
200
- * Extract HTTP status code from a WebSocket or upgrade error, if present.
201
- */
202
- export function statusFromError(e) {
203
- if (!e) return null
204
- if (typeof e === 'number') return e
205
- if (e.status && typeof e.status === 'number') return e.status
206
- if (e.code && typeof e.code === 'number') return e.code
207
- if (e.statusCode && typeof e.statusCode === 'number') return e.statusCode
208
- const m = /Unexpected server response:\s*(\d{3})/.exec((e.message || e.reason || String(e)))
209
- return m ? Number(m[1]) : null
210
- }
211
-
212
- /**
213
- * Authoritative explanations for HTTP status codes returned by the relay on connection upgrade.
214
- */
215
- export function explainStatus(code, relay = '', opts = {}) {
216
- const c = Number(code)
217
- if (c === 401) {
218
- if (opts && opts.hasToken === false) {
219
- return 'this room needs a token of your own. Ask the room owner to mint you one.'
220
- }
221
- return 'the relay rejected your token. It may have expired -- ask the room owner for a new invite.'
222
- }
223
- if (c === 402) return 'this room is over its plan limit. Contact support@getgrm.tech and we will sort it out.'
224
- if (c === 403) return 'the relay refused this room or path for your token. You are outside the folders you were given.'
225
- if (c === 429) return 'this room is FULL, so you were not let in (Q431). A relay admits a limited number of connections per room -- 8 on the free tier -- and this room is at that ceiling. Note it counts CONNECTIONS, not people: one agent holding several files holds several. If you run the relay, raise it with HIVE_SEATS; otherwise ask whoever does, or contact support@getgrm.tech.'
226
- if (c === 503) return 'the relay is not serving this connection. Either the whole relay is at capacity (Q430 sockets, Q438 open files, or Q439 files held by one participant) or it could not check your licence and is failing closed rather than guessing. It may also be restarting -- if this lasts, contact support@getgrm.tech.'
227
- if (c) return `the relay answered ${c} instead of accepting the connection. Contact support@getgrm.tech.`
228
- return `the relay at ${relay || 'the relay'} cannot be reached. It may be down, asleep, or blocked by a network. Contact support@getgrm.tech if this continues.`
229
- }
230
-
1
+ // WHY FILES ARE NOT SYNCING — asked of the relay, not guessed from the symptom.
2
+ //
3
+ // THE DEFECT THIS REPLACES.
4
+ // Two places told an operator why their files were not arriving: sync.js, when file docs went
5
+ // quiet, and hive-mcp.js, when hive_join failed to sync. Both carried the same three faults.
6
+ //
7
+ // 1. Both printed the literal string "<relay-http>/health" -- a placeholder, never
8
+ // substituted, while the relay URL sat in scope a few lines away.
9
+ // 2. Both named only Q430 and only limits.maxConnsTotal. limits.js:176 ALSO refuses with
10
+ // Q431 on a PER-ROOM cap, and the two need different fixes: HIVE_MAX_CONNS_TOTAL for the
11
+ // first, HIVE_SEATS for the second. An operator hitting Q431 and following that advice
12
+ // raises a limit that was never the constraint, sees no change, and is entitled to
13
+ // conclude the product is broken.
14
+ // 3. Both told a HUMAN to go read /health. The client can read it itself, in the same
15
+ // breath, and say which cause it actually is.
16
+ //
17
+ // MEASURED, 21 Sep 2026. A third agent joined room `night` and announced that 49 files could
18
+ // not connect, "the usual cause is the relay being full (Q430)". At that moment the relay
19
+ // reported 56 connections of 512. limits.js keys its per-room counter on the FILE-room, and
20
+ // each file held three connections against a cap of 64. Q430 needed 512 and had 56. Q431 needed
21
+ // 64 and had 3. NEITHER REFUSAL COULD HAVE FIRED.
22
+ //
23
+ // What had actually happened: /health went rooms 44 -> 0 -> 29 -> 31 -> 44 over a few minutes.
24
+ // The relay dropped its room state and every client began reconnecting, one socket per file.
25
+ //
26
+ // "The relay is full" and "the relay just dropped everyone and we are all reconnecting" produce
27
+ // an IDENTICAL local symptom -- N file docs with synced=false -- and need opposite responses: a
28
+ // config change, or thirty seconds of patience. The difference is a number only the relay has.
29
+ // So ask it.
30
+ //
31
+ // ONE PRODUCER, ON PURPOSE. learnings/repair-the-class-not-the-copy.md exists in this repo
32
+ // because a fix landed on one instance and the class survived into two more tickets. Two
33
+ // hand-written copies of a diagnosis is that state exactly: they had already drifted (the
34
+ // hive-mcp.js copy listed three candidate causes, the sync.js copy asserted one). This module is
35
+ // the only place the wording lives, and tests/relay-diagnosis-test.mjs COUNTS the producers so a
36
+ // third copy fails a test rather than shipping.
37
+
38
+ import { relayFetch } from './hive-transport.js'
39
+
40
+ /** A free-tier per-room cap. At or below this, Q431 is the likely refusal and seats are the fix. */
41
+ export const FREE_TIER_ROOM_CAP = 8
42
+
43
+ /** Fraction of maxConnsTotal at which "the relay is effectively full" is the right reading. */
44
+ export const NEAR_CAPACITY = 0.9
45
+
46
+ /**
47
+ * Turn a websocket relay URL into its /health URL.
48
+ * Exported because a caller that cannot fetch still wants to NAME the URL correctly, which is
49
+ * the whole of defect (1) above.
50
+ */
51
+ export function healthUrlFor(relay) {
52
+ const base = String(relay || '')
53
+ .replace(/^wss:/i, 'https:')
54
+ .replace(/^ws:/i, 'http:')
55
+ .replace(/\/+$/, '')
56
+ return /^https?:/i.test(base) ? `${base}/health` : null
57
+ }
58
+
59
+ /**
60
+ * Ask the relay for its own numbers.
61
+ *
62
+ * NEVER THROWS, AND NEVER BLOCKS FOR LONG. This runs inside a diagnostic, and a diagnostic that
63
+ * can hang is a diagnostic that withholds the warning it exists to deliver. The warning matters
64
+ * more than the detail: a failed probe still produces a message, with the failure named.
65
+ *
66
+ * `ok` means "the probe worked", NOT "the relay is healthy". Conflating those is how a monitor
67
+ * reports the absence of an answer as a clean bill of health.
68
+ */
69
+ export async function probeRelayHealth(relay, { timeoutMs = 2500, fetchImpl } = {}) {
70
+ const url = healthUrlFor(relay)
71
+ if (!url) return { ok: false, url: null, error: 'no usable relay URL' }
72
+ // relayFetch, not the built-in fetch: behind a proxy the built-in one cannot reach the relay at all,
73
+ // and this diagnosis would then blame the relay for what is this machine's network.
74
+ const f = fetchImpl || relayFetch
75
+ if (!f) return { ok: false, url, error: 'no fetch available in this runtime' }
76
+ try {
77
+ const ac = new AbortController()
78
+ const t = setTimeout(() => ac.abort(), timeoutMs)
79
+ if (t && t.unref) t.unref()
80
+ let res
81
+ try { res = await f(url, { signal: ac.signal }) } finally { clearTimeout(t) }
82
+ if (!res || !res.ok) return { ok: false, url, error: `HTTP ${res ? res.status : 'no response'}` }
83
+ const j = await res.json()
84
+ return { ok: true, url, health: j }
85
+ } catch (e) {
86
+ const name = e && e.name
87
+ const why = name === 'AbortError' ? `no response in ${timeoutMs}ms` : ((e && e.message) || 'unreachable')
88
+ return { ok: false, url, error: why }
89
+ }
90
+ }
91
+
92
+ /** The four readings this can produce. Named so a test can assert on the verdict, not the prose. */
93
+ export const RELAY_FULL = 'RELAY_FULL'
94
+ export const ROOM_CAP = 'ROOM_CAP'
95
+ export const NOT_CAPACITY = 'NOT_CAPACITY'
96
+ export const UNREACHABLE = 'UNREACHABLE'
97
+
98
+ /**
99
+ * Decide what the numbers mean, and say it in a way an operator can act on.
100
+ *
101
+ * Returns { verdict, text }. The verdict is for tests and for callers that want to branch; the
102
+ * text is what a human reads. Keeping them separate is what lets the test pin the DECISION
103
+ * rather than the wording, so rephrasing the advice does not break the suite and changing the
104
+ * advice does.
105
+ *
106
+ * @param {object} probe the result of probeRelayHealth()
107
+ * @param {object} opts
108
+ * @param {number} opts.stuck how many files are not synced
109
+ * @param {string} opts.what 'files' (sync.js) or 'join' (hive-mcp.js) -- changes the lead only
110
+ */
111
+ export function diagnose(probe, { stuck = 0, what = 'files' } = {}) {
112
+ const h = (probe && probe.ok && probe.health) || null
113
+ const url = (probe && probe.url) || 'the relay'
114
+
115
+ const lead = what === 'join'
116
+ ? 'The relay did not sync this folder.'
117
+ : `${stuck} file(s) are not synced and will READ AS EMPTY until this clears.`
118
+
119
+ if (!h) {
120
+ // A probe that failed is MORE specific than any guess, not less: the relay is unreachable
121
+ // over HTTP from this machine, so capacity is not the question and no limit needs changing.
122
+ return {
123
+ verdict: UNREACHABLE,
124
+ text: [
125
+ lead,
126
+ `I could not reach ${url}${probe && probe.error ? ` (${probe.error})` : ''}, so the relay may be down,`,
127
+ `restarting, or blocked from this machine. THAT IS NOT A CAPACITY PROBLEM and no limit needs raising.`,
128
+ `Check that the relay is running and that this host can reach it.`,
129
+ ].join(' '),
130
+ }
131
+ }
132
+
133
+ const total = Number(h.limits && h.limits.maxConnsTotal) || 0
134
+ const conns = Number(h.connections) || 0
135
+ const perRoom = Number(h.connsPerRoom) || Number(h.limits && h.limits.maxConnsPerRoom) || 0
136
+ const seats = h.seats ? ` (${h.seats} seats)` : ''
137
+ const facts = `I asked ${url}: ${conns} of ${total} connections in use, ${h.rooms || 0} rooms, per-room cap ${perRoom}${seats}.`
138
+
139
+ // ONE SOCKET PER PARTICIPANT has its own ceiling: the files open across every participant's
140
+ // shared socket (Q438). Checked first, because at that ceiling the socket count looks healthy.
141
+ const chanCap = Number(h.limits && h.limits.maxChannelsTotal) || 0
142
+ const chans = Number(h.channels) || 0
143
+ if (Number(h.mux) >= 1 && chanCap && chans >= chanCap * NEAR_CAPACITY) {
144
+ return {
145
+ verdict: RELAY_FULL,
146
+ text: [lead, `I asked ${url}: ${chans} of ${chanCap} open files in use across all participants.`,
147
+ `THE RELAY IS AT ITS OPEN-FILE CAPACITY — this is Q438. Raise HIVE_MAX_CHANNELS_TOTAL on the relay,`,
148
+ `or narrow what syncs.`].join(' '),
149
+ }
150
+ }
151
+
152
+ if (total && conns >= total * NEAR_CAPACITY) {
153
+ // Who spends the sockets depends on the transport. A relay offering one-socket mode charges
154
+ // those clients 2 each; the older transport, still spoken by the VS Code extension, the browser
155
+ // panel and HIVE_MUX=0, charges one PER FILE -- and that is what exhausts a relay.
156
+ const who = Number(h.mux) >= 1
157
+ ? `This relay carries each participant's files on one socket (2 sockets a participant), but clients`
158
+ + ` on the older transport -- the VS Code extension, the browser panel, HIVE_MUX=0 -- still open one`
159
+ + ` socket PER FILE, and one large folder on those can exhaust it alone.`
160
+ : `Hivecode opens one socket PER FILE, so a single large folder can exhaust it alone.`
161
+ return {
162
+ verdict: RELAY_FULL,
163
+ text: [lead, facts,
164
+ `THE RELAY IS AT OR NEAR CAPACITY — this is Q430.`, who,
165
+ `Raise HIVE_MAX_CONNS_TOTAL on the relay, or narrow what syncs.`].join(' '),
166
+ }
167
+ }
168
+
169
+ if (perRoom && perRoom <= FREE_TIER_ROOM_CAP) {
170
+ // The case the old message could not express at all, and the one where its advice was
171
+ // actively harmful: total headroom is fine, so raising the total changes nothing.
172
+ return {
173
+ verdict: ROOM_CAP,
174
+ text: [lead, facts,
175
+ `The relay has total headroom, but its PER-ROOM cap is ${perRoom} and it refuses past that with Q431.`,
176
+ `That is a free-tier default. Set HIVE_SEATS on the relay — raising HIVE_MAX_CONNS_TOTAL will NOT help,`,
177
+ `because the total is not the constraint.`].join(' '),
178
+ }
179
+ }
180
+
181
+ return {
182
+ verdict: NOT_CAPACITY,
183
+ text: [lead, facts,
184
+ `CAPACITY IS NOT THE CAUSE: ${Math.max(0, total - conns)} of ${total} connections are free and the per-room`,
185
+ `cap is ${perRoom}. These sockets are most likely still reconnecting — the relay drops room state on`,
186
+ `restart and every file reconnects separately, which looks exactly like this for a minute or two.`,
187
+ `Wait and re-check before changing any limit. If it persists, the room may be secured and this client`,
188
+ `may carry no token, or the relay may be failing its licence check closed.`].join(' '),
189
+ }
190
+ }
191
+
192
+ /**
193
+ * The whole diagnosis in one call, for the two callers that want it.
194
+ * `trailer` is appended verbatim -- sync.js needs "do not trust this folder's contents", a
195
+ * failed join needs its own instructions, and neither belongs in the capacity reasoning.
196
+ */
197
+ export async function explainNotSyncing(relay, { stuck = 0, what = 'files', trailer = '', timeoutMs = 2500, fetchImpl } = {}) {
198
+ const probe = await probeRelayHealth(relay, { timeoutMs, fetchImpl })
199
+ const d = diagnose(probe, { stuck, what })
200
+ return { ...d, probe, text: trailer ? `${d.text} ${trailer}` : d.text }
201
+ }
202
+
203
+ /**
204
+ * Extract HTTP status code from a WebSocket or upgrade error, if present.
205
+ */
206
+ export function statusFromError(e) {
207
+ if (!e) return null
208
+ if (typeof e === 'number') return e
209
+ if (e.status && typeof e.status === 'number') return e.status
210
+ if (e.code && typeof e.code === 'number') return e.code
211
+ if (e.statusCode && typeof e.statusCode === 'number') return e.statusCode
212
+ const m = /Unexpected server response:\s*(\d{3})/.exec((e.message || e.reason || String(e)))
213
+ return m ? Number(m[1]) : null
214
+ }
215
+
216
+ /**
217
+ * Authoritative explanations for HTTP status codes returned by the relay on connection upgrade.
218
+ */
219
+ export function explainStatus(code, relay = '', opts = {}) {
220
+ const c = Number(code)
221
+ if (c === 401) {
222
+ if (opts && opts.hasToken === false) {
223
+ return 'this room needs a token of your own. Ask the room owner to mint you one.'
224
+ }
225
+ return 'the relay rejected your token. It may have expired -- ask the room owner for a new invite.'
226
+ }
227
+ if (c === 402) return 'this room is over its plan limit. Contact support@getgrm.tech and we will sort it out.'
228
+ if (c === 403) return 'the relay refused this room or path for your token. You are outside the folders you were given.'
229
+ if (c === 429) return 'this room is FULL, so you were not let in (Q431). A relay admits a limited number of connections per room -- 8 on the free tier -- and this room is at that ceiling. Note it counts CONNECTIONS, not people: one agent holding several files holds several. If you run the relay, raise it with HIVE_SEATS; otherwise ask whoever does, or contact support@getgrm.tech.'
230
+ if (c === 503) return 'the relay is not serving this connection. Either the whole relay is at capacity (Q430 sockets, Q438 open files, or Q439 files held by one participant) or it could not check your licence and is failing closed rather than guessing. It may also be restarting -- if this lasts, contact support@getgrm.tech.'
231
+ if (c) return `the relay answered ${c} instead of accepting the connection. Contact support@getgrm.tech.`
232
+ return `the relay at ${relay || 'the relay'} cannot be reached. It may be down, asleep, or blocked by a network. Contact support@getgrm.tech if this continues.`
233
+ }
234
+