hivecode-mcp 0.11.5 → 0.12.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/HIVE_RULES.md +127 -41
- package/README.md +250 -13
- package/core.js +14 -146
- package/docs/MCP.md +34 -1
- package/docs/SELF_HOSTING.md +253 -0
- package/hive-activity.js +310 -0
- package/hive-busy.js +88 -0
- package/hive-coord.js +14 -3
- package/hive-gate.js +135 -0
- package/hive-hook-install.js +103 -0
- package/hive-instructions.js +66 -0
- package/hive-mailbox.js +216 -3
- package/hive-mcp.js +760 -45
- package/hive-obligations.js +247 -0
- package/hive-pr-staleness.js +189 -0
- package/hive-privacy.js +263 -0
- package/hive-receipts.js +233 -0
- package/hive-relay-health.js +205 -0
- package/hive-stop-hook.js +241 -0
- package/hive-throttle.js +118 -0
- package/hive-version.js +59 -0
- package/lang-js.js +11 -2
- package/package.json +42 -6
- package/reconcile.cjs +477 -0
- package/sync.js +940 -30
|
@@ -0,0 +1,205 @@
|
|
|
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
|
+
if (total && conns >= total * NEAR_CAPACITY) {
|
|
136
|
+
return {
|
|
137
|
+
verdict: RELAY_FULL,
|
|
138
|
+
text: [lead, facts,
|
|
139
|
+
`THE RELAY IS AT OR NEAR CAPACITY — this is Q430. Hivecode opens one socket PER FILE, so a`,
|
|
140
|
+
`single large folder can exhaust it alone. Raise HIVE_MAX_CONNS_TOTAL on the relay, or narrow`,
|
|
141
|
+
`what syncs.`].join(' '),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (perRoom && perRoom <= FREE_TIER_ROOM_CAP) {
|
|
146
|
+
// The case the old message could not express at all, and the one where its advice was
|
|
147
|
+
// actively harmful: total headroom is fine, so raising the total changes nothing.
|
|
148
|
+
return {
|
|
149
|
+
verdict: ROOM_CAP,
|
|
150
|
+
text: [lead, facts,
|
|
151
|
+
`The relay has total headroom, but its PER-ROOM cap is ${perRoom} and it refuses past that with Q431.`,
|
|
152
|
+
`That is a free-tier default. Set HIVE_SEATS on the relay — raising HIVE_MAX_CONNS_TOTAL will NOT help,`,
|
|
153
|
+
`because the total is not the constraint.`].join(' '),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
verdict: NOT_CAPACITY,
|
|
159
|
+
text: [lead, facts,
|
|
160
|
+
`CAPACITY IS NOT THE CAUSE: ${Math.max(0, total - conns)} of ${total} connections are free and the per-room`,
|
|
161
|
+
`cap is ${perRoom}. These sockets are most likely still reconnecting — the relay drops room state on`,
|
|
162
|
+
`restart and every file reconnects separately, which looks exactly like this for a minute or two.`,
|
|
163
|
+
`Wait and re-check before changing any limit. If it persists, the room may be secured and this client`,
|
|
164
|
+
`may carry no token, or the relay may be failing its licence check closed.`].join(' '),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The whole diagnosis in one call, for the two callers that want it.
|
|
170
|
+
* `trailer` is appended verbatim -- sync.js needs "do not trust this folder's contents", a
|
|
171
|
+
* failed join needs its own instructions, and neither belongs in the capacity reasoning.
|
|
172
|
+
*/
|
|
173
|
+
export async function explainNotSyncing(relay, { stuck = 0, what = 'files', trailer = '', timeoutMs = 2500, fetchImpl } = {}) {
|
|
174
|
+
const probe = await probeRelayHealth(relay, { timeoutMs, fetchImpl })
|
|
175
|
+
const d = diagnose(probe, { stuck, what })
|
|
176
|
+
return { ...d, probe, text: trailer ? `${d.text} ${trailer}` : d.text }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Extract HTTP status code from a WebSocket or upgrade error, if present.
|
|
181
|
+
*/
|
|
182
|
+
export function statusFromError(e) {
|
|
183
|
+
if (!e) return null
|
|
184
|
+
if (typeof e === 'number') return e
|
|
185
|
+
if (e.status && typeof e.status === 'number') return e.status
|
|
186
|
+
if (e.code && typeof e.code === 'number') return e.code
|
|
187
|
+
if (e.statusCode && typeof e.statusCode === 'number') return e.statusCode
|
|
188
|
+
const m = /Unexpected server response:\s*(\d{3})/.exec((e.message || e.reason || String(e)))
|
|
189
|
+
return m ? Number(m[1]) : null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Authoritative explanations for HTTP status codes returned by the relay on connection upgrade.
|
|
194
|
+
*/
|
|
195
|
+
export function explainStatus(code, relay = '') {
|
|
196
|
+
const c = Number(code)
|
|
197
|
+
if (c === 401) return 'the relay rejected your token. It may have expired -- ask the room owner for a new invite.'
|
|
198
|
+
if (c === 402) return 'this room is over its plan limit. Contact support@getgrm.tech and we will sort it out.'
|
|
199
|
+
if (c === 403) return 'the relay refused this room or path for your token. You are outside the folders you were given.'
|
|
200
|
+
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.'
|
|
201
|
+
if (c === 503) return 'the relay is not serving this connection. Either the whole relay is at capacity (Q430) 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.'
|
|
202
|
+
if (c) return `the relay answered ${c} instead of accepting the connection. Contact support@getgrm.tech.`
|
|
203
|
+
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.`
|
|
204
|
+
}
|
|
205
|
+
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* THE ANTI-IDLE GATE.
|
|
4
|
+
*
|
|
5
|
+
* A rule an agent is told to follow is advice. This is the same rule as a lock.
|
|
6
|
+
*
|
|
7
|
+
* THE PROBLEM. hive_wait exists so an agent never goes unreachable: block until someone needs
|
|
8
|
+
* you, do the work, block again. It is in the tool description and it is now in the system
|
|
9
|
+
* prompt (hive-instructions.js). Both are still instructions, and an agent that decides it is
|
|
10
|
+
* finished simply emits a final answer instead of another tool call. Its process is alive, its
|
|
11
|
+
* socket is up, and it is deaf. Everyone waiting on it waits forever, and nothing tells them.
|
|
12
|
+
*
|
|
13
|
+
* You cannot fix that with better wording, for the same reason a claim board that only warns
|
|
14
|
+
* does not stop a write: it is enforcement or it is a suggestion.
|
|
15
|
+
*
|
|
16
|
+
* WHAT THIS IS. A Claude Code `Stop` hook. Claude Code runs it when the main agent is about to
|
|
17
|
+
* end its turn. Returning {"decision":"block","reason":...} makes Claude Code refuse to stop and
|
|
18
|
+
* feed `reason` back as the next input. The agent does not get a vote. So an agent that tries to
|
|
19
|
+
* go idle while it has unread mail or unfinished accepted work is simply not allowed to.
|
|
20
|
+
*
|
|
21
|
+
* WHY IT READS DISK AND NOT THE RELAY. This runs on every turn boundary and has a hard timeout.
|
|
22
|
+
* A network round trip inside that budget would make the hook the least reliable thing in the
|
|
23
|
+
* session. It does not need one: the sync client already mirrors the room to HIVE_CHAT.md and
|
|
24
|
+
* HIVE_TASKS.md on local disk. Reading those is microseconds, works with the relay unreachable,
|
|
25
|
+
* and cannot itself desync the room because it only ever reads.
|
|
26
|
+
*
|
|
27
|
+
* IT BLOCKS ON THREE THINGS, and the middle one is the subtle one:
|
|
28
|
+
*
|
|
29
|
+
* - unread mail addressed to you
|
|
30
|
+
* - a question you DID read and never answered ("read" is not "answered": the cursor advances
|
|
31
|
+
* the moment a message is handed over, so an agent can absorb a direct question mid-work and
|
|
32
|
+
* end its turn having left someone hanging, with nothing unread to block on)
|
|
33
|
+
* - an accepted task still open
|
|
34
|
+
*
|
|
35
|
+
* FOUR GUARDS AGAINST TRAPPING THE AGENT IN A LOOP. A hook that blocks forever is worse than
|
|
36
|
+
* no hook -- it burns the user's tokens and they cannot get their agent back:
|
|
37
|
+
*
|
|
38
|
+
* 1. stop_hook_active. Claude Code sets this when the stop it is asking about is ALREADY the
|
|
39
|
+
* result of a previous block. We always allow then, so a block is never immediately
|
|
40
|
+
* re-applied on top of itself.
|
|
41
|
+
* 2. The watermark advances even when we block. A given message can cause exactly one block,
|
|
42
|
+
* ever. If the agent ignores it, that is the human's problem to see, not ours to enforce.
|
|
43
|
+
* 3. `nudged` -- the same one-block-per-message rule extended across BOTH reasons, so a
|
|
44
|
+
* message cannot charge once as unread mail and again as an unanswered question. Keyed on
|
|
45
|
+
* sender+timestamp, so a genuinely new question still gets its own single nudge.
|
|
46
|
+
* 4. MAX_TASK_BLOCKS. Unlike a message, an open task stays open, so it would re-block on every
|
|
47
|
+
* turn until completed -- including when it CANNOT be completed. After a few consecutive
|
|
48
|
+
* task-only blocks we stand down and let the agent stop.
|
|
49
|
+
*
|
|
50
|
+
* Any failure at all -- no session, unreadable file, malformed JSON, unexpected shape -- exits 0
|
|
51
|
+
* and allows the stop. The hook may make an agent more reliable; it must never make a session
|
|
52
|
+
* unusable.
|
|
53
|
+
*
|
|
54
|
+
* Install with: npx hivecode-hook install
|
|
55
|
+
*/
|
|
56
|
+
import fs from 'fs'
|
|
57
|
+
import path from 'path'
|
|
58
|
+
import { mentionsName, unansweredFor } from './hive-mailbox.js'
|
|
59
|
+
|
|
60
|
+
// Consecutive turns we will block purely because a task is still open, before standing down.
|
|
61
|
+
// Messages are not subject to this: the watermark already makes each one block at most once.
|
|
62
|
+
const MAX_TASK_BLOCKS = 3
|
|
63
|
+
|
|
64
|
+
const SESSION_FILE = '.hive-mcp-session.json' // written by hive-mcp.js on join
|
|
65
|
+
const STATE_FILE = '.hive-stop-hook.json' // ours: watermark, task blocks, questions already nudged
|
|
66
|
+
|
|
67
|
+
// A chat line: "- 19:07:27 Eve (ai): text". Anything else is a continuation of the message
|
|
68
|
+
// above it (messages may contain newlines) or a header comment.
|
|
69
|
+
const CHAT_LINE = /^- (\d{2}:\d{2}:\d{2})\s+(.+?) \((human|ai|system)\): ([\s\S]*)$/
|
|
70
|
+
// A task line: "- [accepted] tmtofacav4mf Ganesh -> PM: do the thing (by Ganesh)"
|
|
71
|
+
const TASK_LINE = /^- \[(\w+)\]\s+(\S+)\s+(.+?) -> (.+?): ([\s\S]*)$/
|
|
72
|
+
|
|
73
|
+
const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null } }
|
|
74
|
+
|
|
75
|
+
/** Fold continuation lines into their message so a multi-line post counts once. */
|
|
76
|
+
export function parseChat(src) {
|
|
77
|
+
const out = []
|
|
78
|
+
for (const line of String(src || '').split(/\r?\n/)) {
|
|
79
|
+
const m = CHAT_LINE.exec(line)
|
|
80
|
+
if (m) out.push({ at: m[1], by: m[2], kind: m[3], text: m[4] })
|
|
81
|
+
else if (out.length && line.trim() && !line.startsWith('#')) out[out.length - 1].text += '\n' + line
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Accepted tasks pointed at `me` that nobody has marked done. */
|
|
87
|
+
export function openTasksFor(src, me) {
|
|
88
|
+
const out = []
|
|
89
|
+
for (const line of String(src || '').split(/\r?\n/)) {
|
|
90
|
+
const m = TASK_LINE.exec(line)
|
|
91
|
+
// `done` and `completed` both appear in the wild depending on how the task was closed;
|
|
92
|
+
// anything that is not explicitly accepted-and-open is not work we should block on.
|
|
93
|
+
if (m && m[1] === 'accepted' && m[4].trim().toLowerCase() === String(me).toLowerCase()) {
|
|
94
|
+
out.push({ id: m[2], from: m[3], text: m[5] })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The whole decision, as a pure function of what is on disk, so the test can drive it without a
|
|
102
|
+
* Claude Code session, a relay, or a room.
|
|
103
|
+
*
|
|
104
|
+
* Returns { block: boolean, reason?: string, seen: number, taskBlocks: number }.
|
|
105
|
+
*/
|
|
106
|
+
export function decide({ chat, tasks, me, seen, taskBlocks, nudged = [] }) {
|
|
107
|
+
const msgs = parseChat(chat)
|
|
108
|
+
const nowSeen = msgs.length
|
|
109
|
+
// A cursor past the end means this is a different (rebuilt or switched) room -- start over
|
|
110
|
+
// rather than treating the whole history as unread and blocking on all of it.
|
|
111
|
+
const from = seen > nowSeen ? 0 : seen
|
|
112
|
+
const fresh = msgs.slice(from)
|
|
113
|
+
|
|
114
|
+
const mail = fresh.filter((m) =>
|
|
115
|
+
m.kind !== 'system' &&
|
|
116
|
+
String(m.by).toLowerCase() !== String(me).toLowerCase() && // never block on our own post
|
|
117
|
+
mentionsName(m.text, me))
|
|
118
|
+
|
|
119
|
+
if (mail.length) {
|
|
120
|
+
const quoted = mail.slice(0, 5).map((m) => ` ${m.by}: ${m.text.replace(/\s+/g, ' ').slice(0, 220)}`)
|
|
121
|
+
const more = mail.length > 5 ? `\n ... and ${mail.length - 5} more` : ''
|
|
122
|
+
return {
|
|
123
|
+
block: true, seen: nowSeen, taskBlocks: 0,
|
|
124
|
+
// Blocking here also spends this message's ONE nudge, so the debt branch below cannot
|
|
125
|
+
// charge for the same message a second time. One block per message, whatever the reason.
|
|
126
|
+
nudged: [...nudged, ...mail.map((m) => m.by + '|' + m.at)].slice(-200),
|
|
127
|
+
reason: `You are in a Hivecode room and ${mail.length} message${mail.length > 1 ? 's' : ''} ` +
|
|
128
|
+
`addressed to you arrived while you were working:\n\n${quoted.join('\n')}${more}\n\n` +
|
|
129
|
+
`Do not stop -- they are waiting on a reply from you. Answer with hive_say (put @Name on ` +
|
|
130
|
+
`it), then go back to hive_wait. hive_wait is your idle state; ending your turn makes you ` +
|
|
131
|
+
`unreachable and nobody in the room finds out.`,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A DEBT IS NOT THE SAME AS UNREAD MAIL, and this is the gap the watermark alone leaves. The
|
|
136
|
+
// cursor advances the instant a message is handed over, so an agent can read a direct
|
|
137
|
+
// question, get absorbed in the work, and end its turn having left someone hanging -- with
|
|
138
|
+
// nothing unread and nothing to block on. The person waiting has no way to find that out.
|
|
139
|
+
//
|
|
140
|
+
// ONE NUDGE PER QUESTION, EVER -- the same rule mail already follows, and it has to be the
|
|
141
|
+
// same rule. A debt survives until it is answered, so anything weaker than remembering which
|
|
142
|
+
// ones we already raised means the identical question blocks turn after turn, and a gate that
|
|
143
|
+
// nags is a gate the user switches off. Keyed on sender+timestamp rather than on sender, so a
|
|
144
|
+
// NEW question from someone we have already nudged about still gets its own one nudge.
|
|
145
|
+
const owed = unansweredFor(msgs, me)
|
|
146
|
+
const unnudged = owed.filter((o) => !nudged.includes(o.from + '|' + o.at))
|
|
147
|
+
if (unnudged.length) {
|
|
148
|
+
const quoted = unnudged.slice(0, 3).map((o) => ` ${o.from}: ${o.text.replace(/\s+/g, ' ').slice(0, 200)}`)
|
|
149
|
+
return {
|
|
150
|
+
block: true, seen: nowSeen, taskBlocks,
|
|
151
|
+
// Recorded BEFORE returning, exactly as the watermark is, so a question cannot block a
|
|
152
|
+
// second time even if whatever happens next goes wrong. Capped so the state file cannot
|
|
153
|
+
// grow without bound in a long-lived room.
|
|
154
|
+
nudged: [...nudged, ...unnudged.map((o) => o.from + '|' + o.at)].slice(-200),
|
|
155
|
+
reason: `You are in a Hivecode room and ${unnudged.length} participant${unnudged.length > 1 ? 's are' : ' is'} ` +
|
|
156
|
+
`still waiting on a reply from you:\n\n${quoted.join('\n')}\n\n` +
|
|
157
|
+
`Answer with hive_say and name them. "Not yet — I am on X, I will come back to you" is a ` +
|
|
158
|
+
`complete answer; silence is the one thing they cannot act on, and nothing tells them you ` +
|
|
159
|
+
`stopped. Then call hive_wait.`,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const open = openTasksFor(tasks, me)
|
|
164
|
+
if (open.length && taskBlocks < MAX_TASK_BLOCKS) {
|
|
165
|
+
const quoted = open.slice(0, 3).map((t) => ` [${t.id}] from ${t.from}: ${t.text.replace(/\s+/g, ' ').slice(0, 220)}`)
|
|
166
|
+
return {
|
|
167
|
+
block: true, seen: nowSeen, taskBlocks: taskBlocks + 1, nudged,
|
|
168
|
+
reason: `You are in a Hivecode room with ${open.length} accepted task${open.length > 1 ? 's' : ''} ` +
|
|
169
|
+
`still open:\n\n${quoted.join('\n')}\n\n` +
|
|
170
|
+
`Finish the work and call hive_complete with the task id, or say why you are blocked with ` +
|
|
171
|
+
`hive_say (@ the person who assigned it). Then call hive_wait. If you genuinely cannot ` +
|
|
172
|
+
`proceed, say so in the room first -- do not just stop, nobody would learn that you had.`,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Standing down on something we have already blocked on MAX_TASK_BLOCKS times is not the same
|
|
177
|
+
// as it being resolved, so hold the counter rather than resetting it while it is still there.
|
|
178
|
+
// Debts and tasks share the counter deliberately: they are the same kind of obligation, and
|
|
179
|
+
// two separate counters would let an agent be pinned for 2x MAX_TASK_BLOCKS turns.
|
|
180
|
+
return { block: false, seen: nowSeen, taskBlocks: open.length ? taskBlocks : 0, nudged }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Walk up from `start` looking for the session file; the hook's cwd is usually but not always
|
|
184
|
+
* the folder the MCP server was launched in. */
|
|
185
|
+
function findSession(start) {
|
|
186
|
+
let dir = path.resolve(start)
|
|
187
|
+
for (let i = 0; i < 4; i++) {
|
|
188
|
+
const p = path.join(dir, SESSION_FILE)
|
|
189
|
+
if (fs.existsSync(p)) return { root: dir, session: readJson(p) }
|
|
190
|
+
const up = path.dirname(dir)
|
|
191
|
+
if (up === dir) break
|
|
192
|
+
dir = up
|
|
193
|
+
}
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function main() {
|
|
198
|
+
let input = ''
|
|
199
|
+
for await (const chunk of process.stdin) input += chunk
|
|
200
|
+
const hook = (() => { try { return JSON.parse(input) } catch { return {} } })()
|
|
201
|
+
|
|
202
|
+
// GUARD 1: this stop is already the product of a block. Never stack another on it.
|
|
203
|
+
if (hook.stop_hook_active) return
|
|
204
|
+
|
|
205
|
+
const found = findSession(hook.cwd || process.cwd())
|
|
206
|
+
if (!found || !found.session || !found.session.dir || !found.session.name) return // not in a room
|
|
207
|
+
|
|
208
|
+
const { root, session } = found
|
|
209
|
+
const dir = path.isAbsolute(session.dir) ? session.dir : path.join(root, session.dir)
|
|
210
|
+
const read = (f) => { try { return fs.readFileSync(path.join(dir, f), 'utf8') } catch { return '' } }
|
|
211
|
+
|
|
212
|
+
const chat = read('HIVE_CHAT.md')
|
|
213
|
+
if (!chat) return // room folder not mirrored yet; nothing to be sure about, so allow
|
|
214
|
+
|
|
215
|
+
const statePath = path.join(root, STATE_FILE)
|
|
216
|
+
const prev = readJson(statePath) || {}
|
|
217
|
+
// Keyed by room: a folder rejoined against a different room must not inherit a watermark from
|
|
218
|
+
// unrelated history -- the same reasoning as the persisted sync ancestor being room-keyed.
|
|
219
|
+
const key = String(session.room || session.dir)
|
|
220
|
+
const st = prev[key] || { seen: 0, taskBlocks: 0, nudged: [] }
|
|
221
|
+
|
|
222
|
+
const d = decide({
|
|
223
|
+
chat, tasks: read('HIVE_TASKS.md'), me: session.name,
|
|
224
|
+
seen: Number(st.seen) || 0, taskBlocks: Number(st.taskBlocks) || 0,
|
|
225
|
+
nudged: Array.isArray(st.nudged) ? st.nudged : [],
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// GUARD 2: write the watermark BEFORE returning the block, so this message can never block a
|
|
229
|
+
// second time even if what happens next goes wrong.
|
|
230
|
+
try {
|
|
231
|
+
prev[key] = { seen: d.seen, taskBlocks: d.taskBlocks, nudged: d.nudged || [], at: Date.now() }
|
|
232
|
+
fs.writeFileSync(statePath, JSON.stringify(prev, null, 2))
|
|
233
|
+
} catch { /* a watermark we cannot persist is a reason to allow, never to fail */ }
|
|
234
|
+
|
|
235
|
+
if (d.block) process.stdout.write(JSON.stringify({ decision: 'block', reason: d.reason }))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Only run when executed as the hook, so the test can import decide()/parseChat() cleanly.
|
|
239
|
+
if (process.argv[1] && /hive-stop-hook\.js$/.test(process.argv[1])) {
|
|
240
|
+
main().catch(() => { }).finally(() => process.exit(0))
|
|
241
|
+
}
|
package/hive-throttle.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// hive-throttle.js — say it again only if they can no longer see it.
|
|
2
|
+
//
|
|
3
|
+
// TWO OPPOSITE WASTES, ONE CAUSE. Hivecode repeats some blocks to an agent that still has them
|
|
4
|
+
// verbatim, and never repeats others to an agent that lost them hours ago. Both are the same
|
|
5
|
+
// missing question: *is this still in their context?*
|
|
6
|
+
//
|
|
7
|
+
// [GLOBAL PROJECT STATE] rebuilt and returned on EVERY hive_wait, unchanged, forever.
|
|
8
|
+
// An agent waiting in a loop is handed the same JSON hundreds of
|
|
9
|
+
// times, and pays for it hundreds of times.
|
|
10
|
+
//
|
|
11
|
+
// HIVE_RULES.md 6,288 bytes handed over ONCE, in the hive_join tool result. It sits
|
|
12
|
+
// in the transcript, so it is exactly what compaction throws away
|
|
13
|
+
// first -- and it is the ROOM-specific half of the rules: lanes, gate
|
|
14
|
+
// commands, who owns what. Six hours in, it is simply gone.
|
|
15
|
+
//
|
|
16
|
+
// WHAT IS *NOT* IN SCOPE, AND THIS WAS WORTH CHECKING BEFORE WRITING ANYTHING. `INSTRUCTIONS`
|
|
17
|
+
// (2,477 chars) is passed to `new Server(..., { instructions })`, which puts it in the agent's
|
|
18
|
+
// SYSTEM PROMPT. A system prompt is not part of the compactable transcript, so those rules are
|
|
19
|
+
// present on every single turn and re-sending them would be the precise waste this file exists
|
|
20
|
+
// to remove. Half of an earlier draft of this module did exactly that.
|
|
21
|
+
//
|
|
22
|
+
// THE PROXY, AND ITS HONEST LIMIT. There is no way to ask an agent what is still in its context.
|
|
23
|
+
// Agent Relay's broker faces the same wall and answers it with a measurement it *does* have --
|
|
24
|
+
// bytes of PTY output since the last reminder:
|
|
25
|
+
//
|
|
26
|
+
// "Output volume is the proxy for 'the agent actually worked and may have compacted the
|
|
27
|
+
// earlier reminder out of its context'. An agent that only sat at the prompt receiving
|
|
28
|
+
// messages still has the previous reminder verbatim in context, so repeating it only
|
|
29
|
+
// burns tokens." -- crates/broker/src/broker/injection_format.rs
|
|
30
|
+
//
|
|
31
|
+
// There is no PTY here. The equivalent measurement is the one thing this server controls exactly:
|
|
32
|
+
// BYTES IT HAS HANDED THE AGENT since the last send. Every tool result goes out through one
|
|
33
|
+
// `text()`, so the count is precise rather than sampled.
|
|
34
|
+
//
|
|
35
|
+
// This is a proxy and nothing more. It does not detect compaction; it estimates that enough has
|
|
36
|
+
// happened for compaction to be plausible. It will sometimes repeat something still in context,
|
|
37
|
+
// and sometimes stay quiet about something already gone. Both failures are cheap. Pretending the
|
|
38
|
+
// number means more than it does would not be.
|
|
39
|
+
//
|
|
40
|
+
// THREE RULES, IN PRIORITY ORDER, AND THE ORDER IS THE DESIGN:
|
|
41
|
+
//
|
|
42
|
+
// 1. never sent -> SEND. They have not seen it.
|
|
43
|
+
// 2. content changed -> SEND. Correctness outranks economy: a suppressed state change is a
|
|
44
|
+
// wrong answer, while a repeated one is only a cost.
|
|
45
|
+
// 3. otherwise -> send only if BOTH the cooldown has elapsed AND enough bytes have gone
|
|
46
|
+
// out. Either gate alone is wrong. Time alone nags an idle agent that
|
|
47
|
+
// still has the text. Bytes alone fires mid-burst on an agent that read
|
|
48
|
+
// it ninety seconds ago.
|
|
49
|
+
|
|
50
|
+
/** Long enough that a burst of work does not trigger a repeat, short enough to catch a stall. */
|
|
51
|
+
export const COOLDOWN_MS = 5 * 60000
|
|
52
|
+
|
|
53
|
+
/** Agent Relay's number, and a reasonable one: ~16k tokens of traffic since they last saw it. */
|
|
54
|
+
export const OUTPUT_BYTES = 64 * 1024
|
|
55
|
+
|
|
56
|
+
/** Cheap, order-insensitive change detection. Not a hash -- a hash would be more code for a
|
|
57
|
+
* comparison that is already exact on the strings these callers pass. */
|
|
58
|
+
const norm = (c) => (typeof c === 'string' ? c : JSON.stringify(c === undefined ? null : c))
|
|
59
|
+
|
|
60
|
+
export function makeThrottle({ now = Date.now, cooldownMs = COOLDOWN_MS, bytes = OUTPUT_BYTES } = {}) {
|
|
61
|
+
// key -> { at, content, bytesSince, sends }
|
|
62
|
+
const seen = new Map()
|
|
63
|
+
let noted = 0 // total bytes handed to the agent, for reporting only
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Record output handed to the agent.
|
|
67
|
+
*
|
|
68
|
+
* Counting for a key starts only once that key has been sent at least once -- the volume only
|
|
69
|
+
* means anything relative to "since you last saw this". Agent Relay makes the same choice and
|
|
70
|
+
* for the same reason; a counter running before the first send would make the first repeat fire
|
|
71
|
+
* immediately on a busy agent.
|
|
72
|
+
*/
|
|
73
|
+
function note(n) {
|
|
74
|
+
const add = Math.max(0, Number(n) || 0)
|
|
75
|
+
noted += add
|
|
76
|
+
for (const e of seen.values()) e.bytesSince += add
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function should(key, content) {
|
|
80
|
+
const e = seen.get(key)
|
|
81
|
+
if (!e) return { send: true, why: 'first' }
|
|
82
|
+
if (norm(content) !== e.content) return { send: true, why: 'changed' }
|
|
83
|
+
const waited = now() - e.at
|
|
84
|
+
if (waited < cooldownMs) return { send: false, why: 'recent', waited }
|
|
85
|
+
if (e.bytesSince < bytes) return { send: false, why: 'quiet', bytesSince: e.bytesSince }
|
|
86
|
+
return { send: true, why: 'likely-compacted', waited, bytesSince: e.bytesSince }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sent(key, content) {
|
|
90
|
+
const prev = seen.get(key)
|
|
91
|
+
seen.set(key, { at: now(), content: norm(content), bytesSince: 0, sends: (prev ? prev.sends : 0) + 1 })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** should() and sent() in one call, for the common site: `if (send(k, c)) include(c)`. */
|
|
95
|
+
function send(key, content) {
|
|
96
|
+
const d = should(key, content)
|
|
97
|
+
if (d.send) sent(key, content)
|
|
98
|
+
return d.send
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** What was actually saved, so the claim is measured rather than asserted. */
|
|
102
|
+
function stats() {
|
|
103
|
+
const out = { keys: {}, bytesHandled: noted }
|
|
104
|
+
for (const [k, e] of seen.entries()) out.keys[k] = { sends: e.sends, bytesSince: e.bytesSince }
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { note, should, sent, send, stats }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Cheap enough to be worth repeating; says nothing an agent's system prompt already carries.
|
|
112
|
+
* Deliberately a POINTER, not a copy: the file is on the agent's own disk, and a second
|
|
113
|
+
* hand-written summary of it here would be one more description to drift. */
|
|
114
|
+
export const RULES_NUDGE =
|
|
115
|
+
'[ROOM RULES] You have been working a while — the room rules you were handed when you joined ' +
|
|
116
|
+
'are probably out of your context by now. They are ROOM-specific (lanes, gate command, who ' +
|
|
117
|
+
'owns what), so the general rules you still have do not cover them. Re-read HIVE_RULES.md in ' +
|
|
118
|
+
'your own folder before your next claim.'
|
package/hive-version.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// hive-version.js — one place that knows what build this is.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE EXISTS, and it is not tidiness.
|
|
4
|
+
//
|
|
5
|
+
// VERSION is load-bearing, not cosmetic. hive_join instructs every arriving agent to announce its
|
|
6
|
+
// version in chat, because two clients running different builds in one room corrupt each other's
|
|
7
|
+
// files with no error on either side — both writes succeed, both are green, and the disagreement
|
|
8
|
+
// surfaces only as lost bytes. That announcement is the ONLY channel that makes a mixed build
|
|
9
|
+
// visible from inside the room. So a version string is not a label; it is the input to the one
|
|
10
|
+
// detector we have.
|
|
11
|
+
//
|
|
12
|
+
// On 20 Sep 2026 three entry points were found reporting a version package.json does not contain:
|
|
13
|
+
// remote-mcp.js said '0.11.5' while live on the hosted relay, hive-mcp-http.js and hive-linear.js
|
|
14
|
+
// both said '1.0.0'. hive-mcp.js had already been fixed months earlier, with a comment explaining
|
|
15
|
+
// exactly why hardcoding it is dangerous — and that fix was never carried to the other three.
|
|
16
|
+
// A detector that lies is strictly worse than no detector, because it is trusted.
|
|
17
|
+
//
|
|
18
|
+
// HIV-P7 closed that by giving each of the four files its own runtime read. Which produced the
|
|
19
|
+
// next defect, and it was measured rather than predicted: with the reader duplicated four times,
|
|
20
|
+
// corrupting ONE copy's fallback string to '9.9.9-DRIFTED' left tests/version-truth-test.mjs at
|
|
21
|
+
// 16 passed, 0 failed. The property guards the version that gets REPORTED and says nothing about
|
|
22
|
+
// the mechanism that produces it, so three of four copies could rot in silence.
|
|
23
|
+
//
|
|
24
|
+
// This codebase has watched that happen before. The extension's reconcile() and the engine's were
|
|
25
|
+
// the same function once; they now share 34 lines and behave differently. Eight lines is not too
|
|
26
|
+
// small to drift — it is exactly small enough that nobody feels the need to check.
|
|
27
|
+
//
|
|
28
|
+
// So the fix is DELETION, not another assertion. One definition, imported by all four. The
|
|
29
|
+
// property becomes true by construction instead of by policing, and a fifth entry point written
|
|
30
|
+
// next year gets it right by importing rather than by remembering.
|
|
31
|
+
//
|
|
32
|
+
// TWO DECISIONS WORTH KEEPING:
|
|
33
|
+
//
|
|
34
|
+
// It READS package.json rather than being told. package.json always ships beside these files —
|
|
35
|
+
// in the repo and in the published tarball — so `new URL('./package.json', import.meta.url)`
|
|
36
|
+
// resolves in both. Anything else (a constant, a build step, a generated file) can disagree with
|
|
37
|
+
// the package the moment `npm version` runs, which is precisely the failure being fixed.
|
|
38
|
+
//
|
|
39
|
+
// IT NEVER THROWS. A version read is not worth a server that will not boot, and a server that
|
|
40
|
+
// refuses to start is a worse outcome than one that says it does not know which build it is. So
|
|
41
|
+
// an unreadable or malformed package.json yields '0.0.0-unknown' — a value that is obviously
|
|
42
|
+
// wrong to a human, will never match a real release, and cannot be mistaken for a true answer.
|
|
43
|
+
// Silence or an empty string could be read as "no mixed build here"; this cannot.
|
|
44
|
+
|
|
45
|
+
import fs from 'fs'
|
|
46
|
+
|
|
47
|
+
/** Recomputed never: a running process cannot change which build it is. */
|
|
48
|
+
export const VERSION = (() => {
|
|
49
|
+
try {
|
|
50
|
+
const v = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version
|
|
51
|
+
if (typeof v === 'string' && v) return v
|
|
52
|
+
} catch { /* fall through — see IT NEVER THROWS above */ }
|
|
53
|
+
return '0.0.0-unknown'
|
|
54
|
+
})()
|
|
55
|
+
|
|
56
|
+
/** The sentinel, exported so callers and tests can name it instead of retyping the literal. */
|
|
57
|
+
export const UNKNOWN_VERSION = '0.0.0-unknown'
|
|
58
|
+
|
|
59
|
+
export default VERSION
|
package/lang-js.js
CHANGED
|
@@ -128,11 +128,20 @@ function bareName(node) {
|
|
|
128
128
|
function declaredNames(src) {
|
|
129
129
|
const s = new Set()
|
|
130
130
|
for (const node of parse(src).body) {
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
let n = node
|
|
132
|
+
if (n.type === 'ExportNamedDeclaration' && n.declaration) n = n.declaration
|
|
133
|
+
if (n.type === 'VariableDeclaration') {
|
|
134
|
+
for (const d of n.declarations) bindingNames(d.id, s)
|
|
135
|
+
} else {
|
|
136
|
+
const nm = bareName(node); if (nm) s.add(nm)
|
|
137
|
+
}
|
|
138
|
+
// Import locals count as declared names too — so if an import is ever dropped and its
|
|
133
139
|
// binding is still used, the dangling-reference check catches it (safety net for the
|
|
134
140
|
// import merge). Keeps the never-emit-broken-code guarantee honest for imports.
|
|
135
141
|
if (node.type === 'ImportDeclaration') for (const sp of node.specifiers) s.add(sp.local.name)
|
|
142
|
+
if (node.type === 'ExportNamedDeclaration' && !node.declaration && node.specifiers) {
|
|
143
|
+
for (const sp of node.specifiers) s.add(sp.exported.name || sp.exported.value || sp.local.name)
|
|
144
|
+
}
|
|
136
145
|
}
|
|
137
146
|
return s
|
|
138
147
|
}
|