hivecode-mcp 0.11.1 → 0.11.3
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/docs/HIVE_RULES.md +41 -0
- package/docs/MCP.md +77 -0
- package/grm-link.cjs +141 -0
- package/hive-coord.js +29 -2
- package/hive-mailbox.js +173 -0
- package/hive-mcp.js +73 -9
- package/icr.js +6 -0
- package/lang-brace.js +72 -7
- package/lang-json.js +29 -2
- package/lang-python.js +228 -0
- package/lang-yaml.js +38 -6
- package/limits.js +346 -0
- package/package.json +29 -8
- package/sync.js +317 -8
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# HIVE RULES — read this first. Everyone in this room (human or AI) follows these.
|
|
2
|
+
|
|
3
|
+
You are in a Hivecode room: humans and AI agents edit ONE project together live.
|
|
4
|
+
These rules keep anyone from destroying another's work. The sync layer enforces
|
|
5
|
+
the hard parts automatically; you do the rest.
|
|
6
|
+
|
|
7
|
+
## Before you touch a file
|
|
8
|
+
1. Read HIVE_CHAT.md — what is everyone doing right now.
|
|
9
|
+
2. Read HIVE_BOARD.md — which files were just rewritten.
|
|
10
|
+
3. If a file you plan to edit appears there, RE-READ it before changing it.
|
|
11
|
+
|
|
12
|
+
## While you work
|
|
13
|
+
4. ANNOUNCE in chat what you are taking before you start.
|
|
14
|
+
5. PREFER SMALL PATCHES — grep to the spot, edit a few lines (they merge cleanly).
|
|
15
|
+
6. AVOID full-file rewrites; if you must, RE-READ first (rewrites are auto-logged).
|
|
16
|
+
7. STAY IN YOUR LANE — read others' areas, but leave edits to the owner.
|
|
17
|
+
|
|
18
|
+
## When things collide
|
|
19
|
+
8. <<<<<<< ======= >>>>>>> markers = a real conflict: keep the right code, delete
|
|
20
|
+
the markers. Never ignore or blindly overwrite.
|
|
21
|
+
9. If your edit was merged/reworked, that is normal — re-read and continue.
|
|
22
|
+
|
|
23
|
+
## Talking
|
|
24
|
+
10. Coordinate in chat. ASK before anything destructive in another's area.
|
|
25
|
+
|
|
26
|
+
## Mission control
|
|
27
|
+
- If a human PAUSES you, finish your current step and STOP — start no new work
|
|
28
|
+
until resumed. A human may reassign your focus anytime; their directive wins.
|
|
29
|
+
|
|
30
|
+
## When you get a ping mid-task (interruptions)
|
|
31
|
+
11. Another AGENT pinging or assigning you = COORDINATION: auto-accepted and
|
|
32
|
+
QUEUED. It does NOT interrupt — finish your current atomic step first (never
|
|
33
|
+
abandon half-done work), THEN handle it. If it's urgent (build broken,
|
|
34
|
+
blocking others), do it now.
|
|
35
|
+
12. ACKNOWLEDGE the moment you see a ping so the sender isn't left hanging, e.g.
|
|
36
|
+
"got it — finishing X (~2 min), then on your auth.js fix".
|
|
37
|
+
13. YOU triage: say whether you'll do it now or after your current step. Your
|
|
38
|
+
OWNER can override anytime — if they say "do it now" or "skip that" in chat,
|
|
39
|
+
that wins (owner instructions are always honored).
|
|
40
|
+
14. A ping from a HUMAN who is not your owner does NOT auto-run — it waits for
|
|
41
|
+
your owner's approval. Keep working; your owner decides.
|
package/docs/MCP.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Hivecode MCP server
|
|
2
|
+
|
|
3
|
+
Lets any MCP-capable AI agent (Claude Code, Claude Desktop, etc.) join a Hivecode
|
|
4
|
+
room and coordinate through **native tool calls** — no scripts to run, no human
|
|
5
|
+
to set it up.
|
|
6
|
+
|
|
7
|
+
## Register it
|
|
8
|
+
|
|
9
|
+
Add Hivecode to your MCP client config. The command runs this repo's server.
|
|
10
|
+
|
|
11
|
+
**Claude Code** (`.mcp.json` in your project, or user settings):
|
|
12
|
+
```json
|
|
13
|
+
{
|
|
14
|
+
"mcpServers": {
|
|
15
|
+
"hivecode": {
|
|
16
|
+
"command": "node",
|
|
17
|
+
"args": ["C:/Users/G1/Desktop/N/hive-mcp.js"],
|
|
18
|
+
"env": { "HIVE_RELAY": "wss://livecode-xoss.onrender.com" }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`HIVE_RELAY` is optional (defaults to the hosted relay).
|
|
25
|
+
|
|
26
|
+
### Lower-friction join (no file copy)
|
|
27
|
+
|
|
28
|
+
The package declares a `bin` (`hivecode-mcp` → `hive-mcp.js`), so once it's
|
|
29
|
+
published to npm the other agent needs no local copy/clone — just:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{ "mcpServers": { "hivecode": { "command": "npx", "args": ["-y", "hivecode-mcp"] } } }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`npx` fetches and runs it. (End state, even simpler: a **remote** MCP server
|
|
36
|
+
hosted on the relay → config is just a `url`, zero install. Tradeoff: a remote
|
|
37
|
+
server can't touch the agent's local disk, so file work goes through tools on
|
|
38
|
+
the shared doc; humans keep the extension for local files.)
|
|
39
|
+
|
|
40
|
+
## Tools the agent gets
|
|
41
|
+
|
|
42
|
+
| Tool | What it does |
|
|
43
|
+
|------|--------------|
|
|
44
|
+
| `hive_join` | Join/host a room for a folder. Auto-joins as an **AI** participant. Returns the room info **and the HIVE_RULES to follow**. |
|
|
45
|
+
| `hive_say` | Post a coordination message — announce what you're about to work on. |
|
|
46
|
+
| `hive_read_chat` | Read the room conversation (read this to coordinate). |
|
|
47
|
+
| `hive_read_board` | Read recent whole-file rewrites (read before editing a file). |
|
|
48
|
+
| `hive_members` | Who's in the room (humans + agents). |
|
|
49
|
+
| `hive_status` | Current session info. |
|
|
50
|
+
| `hive_leave` | Leave the room. |
|
|
51
|
+
|
|
52
|
+
## How an agent uses it
|
|
53
|
+
|
|
54
|
+
1. `hive_join` (with a `link`, a `dir` containing `.hive.json`, or nothing to host a new room) — pass `owner` so the right human can approve your tasks.
|
|
55
|
+
2. Read the returned **HIVE_RULES**.
|
|
56
|
+
3. `hive_read_chat` + `hive_read_board` before touching files.
|
|
57
|
+
4. `hive_say` to announce intent, then edit files in the synced folder.
|
|
58
|
+
5. Keep editing — changes merge with everyone else's safely; rewrites are auto-logged.
|
|
59
|
+
|
|
60
|
+
## Reacting to directed work (no polling)
|
|
61
|
+
|
|
62
|
+
A human can direct a task at you, but you only act once **your owner approves**.
|
|
63
|
+
Don't poll — **block** on `hive_wait`:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
loop:
|
|
67
|
+
result = hive_wait(timeoutSeconds: 60) # returns the instant your owner approves
|
|
68
|
+
if result has APPROVED WORK:
|
|
69
|
+
do it, then hive_complete(id)
|
|
70
|
+
# else just call hive_wait again
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`hive_wait` returns within ~1s of the approval (it reacts to the live update),
|
|
74
|
+
and also wakes on new chat so you stay responsive to teammates.
|
|
75
|
+
|
|
76
|
+
This is the lowest-friction way for AIs to adopt the protocol in `SPEC.md`:
|
|
77
|
+
the agent never runs a command, it just calls tools.
|
package/grm-link.cjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Gives a Hivecode room a MEMORY, by handing its history to GRM.
|
|
2
|
+
//
|
|
3
|
+
// A room already records everything worth remembering -- HIVE_CHAT.md is the participants'
|
|
4
|
+
// conversation, HIVE_BOARD.md is what was rewritten and by whom, HIVE_TASKS.md is the
|
|
5
|
+
// directed work and its approvals. All of it is written to the room folder as ordinary
|
|
6
|
+
// markdown. And GRM already indexes named files from `code_roots.txt`, re-read on every
|
|
7
|
+
// watcher pass, so a path added here takes effect without restarting anything.
|
|
8
|
+
//
|
|
9
|
+
// So the two halves need no new format, daemon or protocol between them: name those files,
|
|
10
|
+
// and an agent that joins the room tomorrow can ask what was decided in it last week --
|
|
11
|
+
// including in conversations it was never present for. Neither product can do that alone.
|
|
12
|
+
//
|
|
13
|
+
// CommonJS on purpose. sync.js is ESM and the VS Code extension is CJS; a .cjs file is the
|
|
14
|
+
// one shape both can load, so this exists ONCE. The duplicated reconcile()/HIVE_RULES text
|
|
15
|
+
// across those two files has already drifted twice and cost real bugs -- not repeating it.
|
|
16
|
+
|
|
17
|
+
const fs = require('fs')
|
|
18
|
+
const os = require('os')
|
|
19
|
+
const path = require('path')
|
|
20
|
+
const crypto = require('crypto')
|
|
21
|
+
|
|
22
|
+
// The room's own record. NOT the room folder: `code_roots.txt` carries a warning written
|
|
23
|
+
// after adding one directory appended 90,215 blocks in a single pass and had to be cut back
|
|
24
|
+
// out of the journal -- "a root is a whole repository unless you check". Naming four files
|
|
25
|
+
// gives the decision history and nothing else.
|
|
26
|
+
const HISTORY_FILES = ['HIVE_CHAT.md', 'HIVE_BOARD.md', 'HIVE_TASKS.md', 'HIVE_MEMBERS.md']
|
|
27
|
+
|
|
28
|
+
function grmHome() {
|
|
29
|
+
const env = (process.env.GRM_HOME || '').trim()
|
|
30
|
+
return env || path.join(os.homedir(), '.grm')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rootsPath() {
|
|
34
|
+
return path.join(grmHome(), 'code_roots.txt')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Keyed on the resolved directory so the same room in two folders gets two blocks, and the
|
|
38
|
+
// same folder rejoined a hundred times keeps exactly one.
|
|
39
|
+
function tagFor(dir) {
|
|
40
|
+
return 'hivecode:' + crypto.createHash('sha256').update(path.resolve(dir)).digest('hex').slice(0, 12)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The file is written by GRM's installer with a UTF-8 BOM. GRM's own reader strips it, but
|
|
44
|
+
// this must not *drop* it on the way through -- the file belongs to GRM, and a tool that
|
|
45
|
+
// silently reformats another tool's config is how you get a bug nobody can find.
|
|
46
|
+
function readRoots(p) {
|
|
47
|
+
const raw = fs.readFileSync(p, 'utf8')
|
|
48
|
+
return raw.charCodeAt(0) === 0xfeff ? { bom: '', body: raw.slice(1) } : { bom: '', body: raw }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeRootsAtomic(p, bom, body) {
|
|
52
|
+
const tmp = p + '.hivecode.tmp'
|
|
53
|
+
fs.writeFileSync(tmp, bom + body, 'utf8')
|
|
54
|
+
fs.renameSync(tmp, p) // the watcher re-reads on every pass; never let it see a half-written file
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Drop any block previously written for this room. Returns the body without it.
|
|
58
|
+
function stripBlock(body, tag) {
|
|
59
|
+
const lines = body.split(/\r?\n/)
|
|
60
|
+
const out = []
|
|
61
|
+
let inside = false
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
if (!inside && line.startsWith('# >>> ' + tag)) { inside = true; continue }
|
|
64
|
+
if (inside) { if (line.startsWith('# <<< ' + tag)) inside = false; continue }
|
|
65
|
+
out.push(line)
|
|
66
|
+
}
|
|
67
|
+
return out.join('\n')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function blockFor(dir, tag, files) {
|
|
71
|
+
const head = `# >>> ${tag} Hivecode room at ${path.resolve(dir)}`
|
|
72
|
+
const why = '# Added by Hivecode so this room\'s decisions are in memory. Safe to delete.'
|
|
73
|
+
const tail = `# <<< ${tag}`
|
|
74
|
+
return [head, why, ...files, tail].join('\n')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Point GRM at this room's history. Idempotent and cheap: it rewrites `code_roots.txt`
|
|
79
|
+
* only when the set of existing history files has actually changed, so it is fine to call
|
|
80
|
+
* on every join and again whenever one of those files is written.
|
|
81
|
+
*
|
|
82
|
+
* Returns { linked, files, reason }. Never throws -- a memory that cannot be wired up is
|
|
83
|
+
* not a reason to fail somebody's join.
|
|
84
|
+
*/
|
|
85
|
+
function linkRoom(dir, log = () => {}) {
|
|
86
|
+
try {
|
|
87
|
+
const p = rootsPath()
|
|
88
|
+
|
|
89
|
+
// DO NOT CREATE THIS FILE. GRM's reader has three states, and they are not two:
|
|
90
|
+
// no file at all -> fall back to the default root; a file naming paths that all fail
|
|
91
|
+
// to resolve -> watch NOTHING, deliberately, so a typo is visible rather than buried.
|
|
92
|
+
// Creating one here containing only HIVE_*.md would therefore convert "GRM indexes my
|
|
93
|
+
// projects" into "GRM indexes this room and nothing else" the moment the paths lag
|
|
94
|
+
// behind the folder. Only ever append to a file GRM already owns.
|
|
95
|
+
if (!fs.existsSync(p)) return { linked: false, files: [], reason: 'GRM is not installed on this machine' }
|
|
96
|
+
|
|
97
|
+
const files = HISTORY_FILES
|
|
98
|
+
.map((f) => path.resolve(dir, f))
|
|
99
|
+
.filter((f) => { try { return fs.statSync(f).isFile() } catch { return false } })
|
|
100
|
+
if (!files.length) return { linked: false, files: [], reason: 'room has not written its history yet' }
|
|
101
|
+
|
|
102
|
+
const tag = tagFor(dir)
|
|
103
|
+
const { bom, body } = readRoots(p)
|
|
104
|
+
const stripped = stripBlock(body, tag)
|
|
105
|
+
const next = stripped.replace(/\s*$/, '') + '\n\n' + blockFor(dir, tag, files) + '\n'
|
|
106
|
+
|
|
107
|
+
if (bom + next === bom + body) return { linked: true, files, reason: 'already linked' } // no write, no watcher churn
|
|
108
|
+
writeRootsAtomic(p, bom, next)
|
|
109
|
+
log(`[hive] room history handed to GRM (${files.length} file(s)) -- agents here can now recall what this room decided before they joined.`)
|
|
110
|
+
return { linked: true, files, reason: 'linked' }
|
|
111
|
+
} catch (e) {
|
|
112
|
+
log(`[hive] could not hand this room's history to GRM: ${e && e.message}`)
|
|
113
|
+
return { linked: false, files: [], reason: String((e && e.message) || e) }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Remove this room from GRM's roots. Called on leave, so a room you are no longer in stops
|
|
119
|
+
* contributing to what your agents recall.
|
|
120
|
+
*/
|
|
121
|
+
function unlinkRoom(dir, log = () => {}) {
|
|
122
|
+
try {
|
|
123
|
+
const p = rootsPath()
|
|
124
|
+
if (!fs.existsSync(p)) return { unlinked: false, reason: 'GRM is not installed on this machine' }
|
|
125
|
+
const tag = tagFor(dir)
|
|
126
|
+
const { bom, body } = readRoots(p)
|
|
127
|
+
if (!body.includes('# >>> ' + tag)) return { unlinked: false, reason: 'was not linked' }
|
|
128
|
+
writeRootsAtomic(p, bom, stripBlock(body, tag).replace(/\s*$/, '') + '\n')
|
|
129
|
+
log('[hive] room history removed from GRM.')
|
|
130
|
+
return { unlinked: true, reason: 'unlinked' }
|
|
131
|
+
} catch (e) {
|
|
132
|
+
return { unlinked: false, reason: String((e && e.message) || e) }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Whether GRM is present at all -- used to decide whether to mention memory in the UI. */
|
|
137
|
+
function grmPresent() {
|
|
138
|
+
try { return fs.existsSync(rootsPath()) } catch { return false }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { linkRoom, unlinkRoom, grmPresent, rootsPath, HISTORY_FILES }
|
package/hive-coord.js
CHANGED
|
@@ -31,8 +31,19 @@
|
|
|
31
31
|
|
|
32
32
|
export function makeCoordinator(shared, self, opts = {}) {
|
|
33
33
|
const ttl = opts.ttl != null ? opts.ttl : 5000
|
|
34
|
+
// A holder may RENEW its own claim, because a long edit should not lose the file mid-write.
|
|
35
|
+
// But renewal cannot be unbounded, or the lease stops being a lease. MEASURED on a 12-agent
|
|
36
|
+
// run: one agent re-claimed site/pricing.md eight times and legal/terms.md seven times and
|
|
37
|
+
// released twice, and since `claim` reset `at` on every call the 60s TTL never once elapsed.
|
|
38
|
+
// Six other agents sensed a live owner for the whole run; sixteen messages asked it to let go.
|
|
39
|
+
// `since` records the FIRST acquisition and survives renewal, so what caps a claim is total
|
|
40
|
+
// hold time rather than time since the holder last touched it.
|
|
41
|
+
const maxHold = opts.maxHold != null ? opts.maxHold : ttl * 3
|
|
34
42
|
const now = opts.now || (() => Date.now())
|
|
35
|
-
const
|
|
43
|
+
const held = (c) => now() - (c.since != null ? c.since : c.at) // total, across renewals
|
|
44
|
+
const cap = (c) => (c.maxHold != null ? c.maxHold : maxHold)
|
|
45
|
+
const overheld = (c) => !!c && held(c) >= cap(c)
|
|
46
|
+
const expired = (c) => !c || (now() - c.at) >= c.ttl || overheld(c)
|
|
36
47
|
|
|
37
48
|
// SENSE: the live, non-expired claim on a region (or null if open).
|
|
38
49
|
const sense = (region) => { const c = shared.get(region); return expired(c) ? null : c }
|
|
@@ -40,7 +51,16 @@ export function makeCoordinator(shared, self, opts = {}) {
|
|
|
40
51
|
// Is this region open to ME? (free, expired, or already mine)
|
|
41
52
|
const free = (region) => { const c = shared.get(region); return expired(c) || c.by === self }
|
|
42
53
|
|
|
43
|
-
|
|
54
|
+
// A renewal keeps the original `since`; a fresh acquisition starts a new one. Claims written
|
|
55
|
+
// before `since` existed fall back to `at`, so an older room stays readable.
|
|
56
|
+
const claim = (region, intent) => {
|
|
57
|
+
const prev = shared.get(region)
|
|
58
|
+
const renewing = !!prev && prev.by === self && !expired(prev)
|
|
59
|
+
shared.set(region, {
|
|
60
|
+
by: self, intent, at: now(), ttl, maxHold,
|
|
61
|
+
since: renewing ? (prev.since != null ? prev.since : prev.at) : now(),
|
|
62
|
+
})
|
|
63
|
+
}
|
|
44
64
|
const release = (region) => { const c = shared.get(region); if (c && c.by === self) shared.delete(region) }
|
|
45
65
|
|
|
46
66
|
// ACQUIRE one region: sense → claim → verify. Returns true iff I now hold it.
|
|
@@ -49,6 +69,13 @@ export function makeCoordinator(shared, self, opts = {}) {
|
|
|
49
69
|
const acquire = (region, intent) => {
|
|
50
70
|
const c = shared.get(region)
|
|
51
71
|
if (c && !expired(c) && c.by !== self) return false // sensed an owner → defer
|
|
72
|
+
// Once total hold is exhausted the region is open to everyone — INCLUDING the outgoing
|
|
73
|
+
// holder, which could then win the re-claim race indefinitely and starve the waiters that
|
|
74
|
+
// maxHold exists to admit. So the overrunning holder stands down for a full maxHold, which
|
|
75
|
+
// is a window every waiter is guaranteed to poll inside. A single-ttl cooldown was tried
|
|
76
|
+
// first and is NOT enough: the holder just waits it out and opens a fresh `since`.
|
|
77
|
+
// The bar lapses on its own, so a file nobody else wants returns to its owner.
|
|
78
|
+
if (c && c.by === self && overheld(c) && held(c) < 2 * cap(c)) return false
|
|
52
79
|
claim(region, intent)
|
|
53
80
|
const after = shared.get(region)
|
|
54
81
|
return !(after && after.by !== self) // verify: did my claim survive?
|
package/hive-mailbox.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// The room mailbox: what has this participant actually read?
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. `hive_wait` used to capture `baseChatLen = chat.length` at the moment it
|
|
4
|
+
// was CALLED, and only ever returned `chat.slice(baseChatLen)`. So anything posted between one
|
|
5
|
+
// wait returning and the next one starting — which is precisely the window where an agent is
|
|
6
|
+
// doing its work — sat below the cursor and was returned by NO code path. Not delayed:
|
|
7
|
+
// unreachable. Two further holes finished the job: a wait that had queued work returned
|
|
8
|
+
// `newChat: []` hardcoded, so the busiest agent in the room saw no messages at all; and
|
|
9
|
+
// mention detection existed only for PAUSED agents, so a working agent could not tell "stop,
|
|
10
|
+
// you are editing the wrong file" from two other agents chatting.
|
|
11
|
+
//
|
|
12
|
+
// Measured on a real four-agent build (JARVIS, 2026-07-28): 345 @mentions in the room log, 13
|
|
13
|
+
// acknowledgements. The PM agent ended up replacing delivery with a bash script that tails
|
|
14
|
+
// HIVE_CHAT.md, which is the verdict on the old design.
|
|
15
|
+
//
|
|
16
|
+
// So the cursor is now a persistent per-participant position that only advances over messages
|
|
17
|
+
// that were actually handed to the agent. This module is deliberately pure — plain arrays in,
|
|
18
|
+
// plain objects out, no Yjs and no I/O — so the delivery rules are unit-testable without a
|
|
19
|
+
// relay, a room, or a network.
|
|
20
|
+
|
|
21
|
+
/** A chat message as stored in the room: { by, kind, at, text }. */
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Does this text address `me`? Matches `@Name`, case-insensitively, at a word boundary so
|
|
25
|
+
* `@PM` does not swallow `@PM-Claude` and `@Hermes` does not match `@Hermes-2`.
|
|
26
|
+
* @param {string} text
|
|
27
|
+
* @param {string} me
|
|
28
|
+
*/
|
|
29
|
+
export function mentionsName(text, me) {
|
|
30
|
+
if (!text || !me) return false
|
|
31
|
+
const escaped = String(me).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
32
|
+
// Trailing boundary: end of string, or anything that cannot continue a name.
|
|
33
|
+
return new RegExp(`@${escaped}(?![\\w-])`, 'i').test(String(text))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Everyone addressed in a message, so a broadcast can be told from a direct call. */
|
|
37
|
+
export function mentionedNames(text) {
|
|
38
|
+
return [...new Set([...String(text || '').matchAll(/@([\w-]+)/g)].map((m) => m[1]))]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* How urgently does this message want attention?
|
|
43
|
+
*
|
|
44
|
+
* 'stop' — act on it before finishing what you are doing
|
|
45
|
+
* 'normal' — read it at your next boundary
|
|
46
|
+
*
|
|
47
|
+
* A marker is an explicit prefix so it is unambiguous, plus a small set of words that mean
|
|
48
|
+
* stop in every room whether or not the sender knew about markers. An agent about to
|
|
49
|
+
* overwrite someone's work is not going to be helped by correct syntax.
|
|
50
|
+
*/
|
|
51
|
+
export function priorityOf(text) {
|
|
52
|
+
const s = String(text || '')
|
|
53
|
+
if (/^\s*\[!\]|^\s*\[stop\]/i.test(s)) return 'stop'
|
|
54
|
+
if (/\b(stop|halt|abort|don'?t\s+(?:edit|touch|write)|revert)\b/i.test(s)) return 'stop'
|
|
55
|
+
return 'normal'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Collapse runs of the identical message into one entry with a count.
|
|
60
|
+
*
|
|
61
|
+
* Not cosmetic. A conflict-notification feedback loop in the same live build put 56 copies of
|
|
62
|
+
* one "⚠ MERGE CONFLICT in Shell.jsx" line into the room in seconds, and 306 of the room's
|
|
63
|
+
* 2,314 chat lines were that loop. Handing an agent 56 identical lines costs it the context it
|
|
64
|
+
* needs to fix the actual problem, and buries the one message that mattered.
|
|
65
|
+
* @param {Array<{text?: string, by?: string}>} messages
|
|
66
|
+
*/
|
|
67
|
+
export function collapseRepeats(messages) {
|
|
68
|
+
const out = []
|
|
69
|
+
for (const m of messages || []) {
|
|
70
|
+
const last = out[out.length - 1]
|
|
71
|
+
if (last && last.by === m.by && last.text === m.text) {
|
|
72
|
+
last.repeated = (last.repeated || 1) + 1
|
|
73
|
+
last.at = m.at ?? last.at // keep the most recent timestamp
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
out.push({ ...m })
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A read cursor over the room chat.
|
|
83
|
+
*
|
|
84
|
+
* @param {{me: string, startAt?: number}} options
|
|
85
|
+
* me this participant's display name
|
|
86
|
+
* startAt where to begin. On a first join this is the current chat length, so an agent is
|
|
87
|
+
* not handed the room's entire history; on a resume it is the persisted cursor, so
|
|
88
|
+
* nothing posted while the process was down is lost.
|
|
89
|
+
*/
|
|
90
|
+
export function createMailbox({ me, startAt = 0 }) {
|
|
91
|
+
let cursor = Math.max(0, Number(startAt) || 0)
|
|
92
|
+
|
|
93
|
+
/** Read without consuming. `advance` is what `take` does; kept separate so a peek is safe. */
|
|
94
|
+
function collect(all, { advance }) {
|
|
95
|
+
const list = Array.isArray(all) ? all : []
|
|
96
|
+
// A cursor past the end means this position belongs to a different document — a room
|
|
97
|
+
// rebuilt from scratch, which the recovery procedure for a poisoned room requires. Reset
|
|
98
|
+
// to the beginning rather than clamping to the end: clamping would deliver nothing, for
|
|
99
|
+
// ever, silently. Re-reading a short fresh chat costs an agent a few lines; going
|
|
100
|
+
// permanently deaf costs it the room.
|
|
101
|
+
if (cursor > list.length) cursor = 0
|
|
102
|
+
|
|
103
|
+
const fresh = list.slice(cursor)
|
|
104
|
+
const end = list.length
|
|
105
|
+
|
|
106
|
+
// An agent's own messages are not mail. It used to be woken by its own hive_say, and in a
|
|
107
|
+
// notification storm most of the flood was self-authored — an agent filling its own inbox.
|
|
108
|
+
const incoming = fresh.filter((m) => m && m.by !== me)
|
|
109
|
+
const messages = collapseRepeats(incoming)
|
|
110
|
+
const mentions = messages.filter((m) => mentionsName(m.text, me))
|
|
111
|
+
const urgent = mentions.filter((m) => priorityOf(m.text) === 'stop')
|
|
112
|
+
|
|
113
|
+
if (advance) cursor = end
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
messages,
|
|
117
|
+
mentions,
|
|
118
|
+
mentioned: mentions.length > 0,
|
|
119
|
+
priority: urgent.length ? 'stop' : 'normal',
|
|
120
|
+
unreadCount: incoming.length,
|
|
121
|
+
cursor,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
/** Current position, for persisting across process restarts. */
|
|
127
|
+
get cursor() { return cursor },
|
|
128
|
+
/** Move the cursor without delivering anything (used at join). */
|
|
129
|
+
seek(n) { cursor = Math.max(0, Number(n) || 0); return cursor },
|
|
130
|
+
/** Deliver everything unread and mark it read. Every return path must go through this. */
|
|
131
|
+
take(all) { return collect(all, { advance: true }) },
|
|
132
|
+
/** Look at what is unread without marking it read. */
|
|
133
|
+
peek(all) { return collect(all, { advance: false }) },
|
|
134
|
+
/** Is there anything at all? Cheap enough to call in a wake predicate. */
|
|
135
|
+
hasUnread(all) { return collect(all, { advance: false }).unreadCount > 0 },
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** One-line rendering used in tool output, with the repeat count when a run was collapsed. */
|
|
140
|
+
export function formatMessage(m) {
|
|
141
|
+
const times = m.repeated > 1 ? ` (×${m.repeated})` : ''
|
|
142
|
+
return `${m.at || ''} ${m.by}(${m.kind || 'ai'}): ${m.text}${times}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* AMBIENT DIGEST -- what to do with room chatter nobody addressed to you.
|
|
147
|
+
*
|
|
148
|
+
* Un-addressed messages were handed to every agent in full, on every wake. In a room of N
|
|
149
|
+
* agents that means each one pays for the other N-1's conversation every single turn: O(N^2)
|
|
150
|
+
* context, so TOKEN COST -- not the relay -- becomes the real ceiling on room size. Measured:
|
|
151
|
+
* the relay holds 8 clients per room, converging in 1.0s on 45 MB. What actually stops a big
|
|
152
|
+
* room is this block of text.
|
|
153
|
+
*
|
|
154
|
+
* The COUNT stays exact and the newest messages still arrive in full. Only the older bodies
|
|
155
|
+
* are withheld, and the agent is told the number and how to read them, so nothing is hidden --
|
|
156
|
+
* it just stops being paid for unasked. An agent that wants the whole log calls hive_read_chat.
|
|
157
|
+
*
|
|
158
|
+
* Returns '' when there is nothing, so a caller can drop it with a falsy check.
|
|
159
|
+
*/
|
|
160
|
+
export function ambientDigest(messages, { keep = 4 } = {}) {
|
|
161
|
+
const list = Array.isArray(messages) ? messages : []
|
|
162
|
+
if (!list.length) return ''
|
|
163
|
+
const shown = keep > 0 ? list.slice(-keep) : []
|
|
164
|
+
const hidden = list.length - shown.length
|
|
165
|
+
const head = 'room activity (' + list.length +
|
|
166
|
+
(hidden ? ', newest ' + shown.length + ' shown' : '') + '):'
|
|
167
|
+
const tail = hidden
|
|
168
|
+
? '\n... ' + hidden + ' earlier message(s) withheld to keep your context small'
|
|
169
|
+
+ ' -- hive_read_chat for the full log'
|
|
170
|
+
: ''
|
|
171
|
+
if (!shown.length) return head + tail
|
|
172
|
+
return head + '\n' + shown.map(formatMessage).join('\n') + tail
|
|
173
|
+
}
|
package/hive-mcp.js
CHANGED
|
@@ -34,7 +34,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot
|
|
|
34
34
|
if (!process.env.HIVE_PROVENANCE) process.env.HIVE_PROVENANCE = 'on'
|
|
35
35
|
if (!process.env.HIVE_FORK_GATE) process.env.HIVE_FORK_GATE = 'on'
|
|
36
36
|
import { startSync, parseLink } from './sync.js'
|
|
37
|
-
import { createMailbox, formatMessage, mentionsName, priorityOf } from './hive-mailbox.js'
|
|
37
|
+
import { createMailbox, formatMessage, mentionsName, mentionedNames, priorityOf, ambientDigest } from './hive-mailbox.js'
|
|
38
38
|
|
|
39
39
|
const DEFAULT_RELAY = process.env.HIVE_RELAY || 'wss://livecode-xoss.onrender.com'
|
|
40
40
|
let session = null // { hive, room, relay, dir, name, mailbox }
|
|
@@ -88,7 +88,26 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
|
|
|
88
88
|
// persist the token too, so a re-join from .hive.json keeps the grant (the
|
|
89
89
|
// invitee pastes the link once; subsequent joins just read the folder config).
|
|
90
90
|
fs.writeFileSync(path.join(ROOT, '.hive.json'), JSON.stringify({ relay, room, ...(useToken ? { token: useToken } : {}) }, null, 2))
|
|
91
|
-
|
|
91
|
+
// The ACCOUNT licence is passed through but NOT persisted to .hive.json above, and that
|
|
92
|
+
// asymmetry is deliberate. `.hive.json` sits in the project folder, so it gets committed
|
|
93
|
+
// and it gets shared -- fine for a room invite, which is what it is for, and wrong for a
|
|
94
|
+
// billing credential. startSync resolves the licence from $HIVE_LICENSE (this server's env
|
|
95
|
+
// block) or ~/.hivecode/config.json instead, both of which are per-machine.
|
|
96
|
+
// A refused folder lock must reach the AGENT as a readable instruction, not as a stack
|
|
97
|
+
// trace. An agent that gets an opaque failure retries, and two of them retrying on one
|
|
98
|
+
// folder is precisely the situation the lock exists to prevent.
|
|
99
|
+
let hive
|
|
100
|
+
try {
|
|
101
|
+
hive = startSync({ relay, room, dir, name, kind: 'ai', owner, token: useToken, log: () => {} })
|
|
102
|
+
} catch (e) {
|
|
103
|
+
if (e && e.code === 'EHIVELOCKED') {
|
|
104
|
+
return text(
|
|
105
|
+
`${e.message}\n\n` +
|
|
106
|
+
`Do NOT retry this folder. Either join with a different \`dir\`, or stop process ${e.pid} first.`,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
throw e
|
|
110
|
+
}
|
|
92
111
|
session = { hive, room, relay, dir, name, owner, mailbox: null }
|
|
93
112
|
persistSession({ dir, name, owner }) // survive MCP host restarts (see tryResumeSession)
|
|
94
113
|
// wait for the first sync so members/board/rules are ready
|
|
@@ -406,7 +425,20 @@ function enforceDcoLock(opts = {}) {
|
|
|
406
425
|
// matters most here: an 0.10.0 client in a room with an 0.11.1 one is invisible in the audit
|
|
407
426
|
// log while its roommate is named, so the room's own membership record disagrees with itself.
|
|
408
427
|
// Also carries the ICR fix that stopped a whole-file rewrite duplicating every declaration.
|
|
409
|
-
|
|
428
|
+
// VERSION is load-bearing, not cosmetic: the join message (see buildJoinReply) instructs every
|
|
429
|
+
// arriving agent to announce it in chat, because a MIXED BUILD in one room causes data loss and
|
|
430
|
+
// this is the only way anyone finds out. Hardcoding it meant that the moment package.json was
|
|
431
|
+
// bumped the two disagreed silently -- so the one mechanism for detecting a mixed build was
|
|
432
|
+
// itself reporting the wrong build. Read the truth instead; package.json always ships in the
|
|
433
|
+
// tarball and sits beside this file in both the repo and the published package.
|
|
434
|
+
// A failed read must never stop the server from starting, so it falls back rather than throwing.
|
|
435
|
+
export const VERSION = (() => {
|
|
436
|
+
try {
|
|
437
|
+
const v = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version
|
|
438
|
+
if (typeof v === 'string' && v) return v
|
|
439
|
+
} catch { /* fall through */ }
|
|
440
|
+
return '0.0.0-unknown'
|
|
441
|
+
})()
|
|
410
442
|
const server = new Server({ name: 'hivecode', version: VERSION }, { capabilities: { tools: {} } })
|
|
411
443
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
|
|
412
444
|
async function dispatch(req) {
|
|
@@ -425,7 +457,18 @@ async function dispatch(req) {
|
|
|
425
457
|
// a human reading the log, not just to the recipient's tool output.
|
|
426
458
|
const body = args.urgent && !/^\s*\[!\]/.test(raw) ? `[!] ${raw}` : raw
|
|
427
459
|
requireSession().hive.say(body)
|
|
428
|
-
|
|
460
|
+
// ADDRESSING IS THE PROTOCOL, and a rule an agent merely read is a rule it forgets once
|
|
461
|
+
// its context fills. So the tool itself answers back: a message that named nobody is
|
|
462
|
+
// reported as landing in everyone's ambient digest, where it is meant to be skipped.
|
|
463
|
+
// Immediate, per-call feedback, at the moment the mistake is made.
|
|
464
|
+
const named = mentionedNames(body)
|
|
465
|
+
if (!named.length) {
|
|
466
|
+
return text(`sent: ${body}\n\nNOBODY WAS ADDRESSED. This went to every agent's "room ` +
|
|
467
|
+
`activity", where they are told to skip it — so assume no one acts on it. Name the ` +
|
|
468
|
+
`agents you actually need with @Name (hive_members lists who is here). Use an ` +
|
|
469
|
+
`unaddressed message only for a genuine announcement to the whole room.`)
|
|
470
|
+
}
|
|
471
|
+
return text(`sent to ${named.map((n) => '@' + n).join(', ')}: ${body}`)
|
|
429
472
|
}
|
|
430
473
|
case 'hive_read_chat': {
|
|
431
474
|
const me = requireSession().name
|
|
@@ -456,14 +499,35 @@ async function dispatch(req) {
|
|
|
456
499
|
if (!got.unreadCount && !tasks.length) return text('✓ nothing waiting — nobody is calling you and you have no approved work. Carry on.')
|
|
457
500
|
const parts = []
|
|
458
501
|
if (got.priority === 'stop') {
|
|
502
|
+
// HANDLING URGENT MAIL COUNTS AS LIVENESS.
|
|
503
|
+
//
|
|
504
|
+
// Claims evaporate after 5 minutes so a crashed agent never deadlocks the room.
|
|
505
|
+
// But an agent that is preempted -- told to stop and deal with something else --
|
|
506
|
+
// is not crashed, it is obeying. Without this refresh it loses the file it was
|
|
507
|
+
// halfway through editing WHILE DOING WHAT IT WAS ASKED, another agent takes the
|
|
508
|
+
// claim, and it resumes writing into a region it no longer owns. The agent gets
|
|
509
|
+
// punished for complying, and the resulting clobber looks like a merge bug.
|
|
510
|
+
//
|
|
511
|
+
// Re-claiming a region you already hold is safe: acquire() only defers when a
|
|
512
|
+
// DIFFERENT owner holds it, so this refreshes `at` and never steals.
|
|
513
|
+
//
|
|
514
|
+
// One refresh buys another full TTL. A long interruption stays safe because the
|
|
515
|
+
// rules have the agent calling hive_check between steps of the urgent work too.
|
|
516
|
+
try {
|
|
517
|
+
for (const c of s.hive.claimsBoard()) {
|
|
518
|
+
if (c.by === s.name) s.hive.claim(c.region, c.intent || 'edit')
|
|
519
|
+
}
|
|
520
|
+
} catch { /* delivering the urgent message matters more than the refresh */ }
|
|
459
521
|
parts.push('🛑 URGENT — someone needs you to STOP what you are doing and handle this FIRST:')
|
|
522
|
+
parts.push('(your claims were just refreshed — the files you hold stay yours while you deal with this)')
|
|
460
523
|
parts.push(got.mentions.filter((m) => priorityOf(m.text) === 'stop').map(formatMessage).join('\n'))
|
|
461
524
|
} else if (got.mentioned) {
|
|
462
525
|
parts.push(`📣 ${got.mentions.length} message(s) addressed to YOU — answer at your next safe stopping point:`)
|
|
463
526
|
parts.push(got.mentions.map(formatMessage).join('\n'))
|
|
464
527
|
}
|
|
465
528
|
const ambient = got.messages.filter((m) => !mentionsName(m.text, s.name))
|
|
466
|
-
|
|
529
|
+
const dig = ambientDigest(ambient)
|
|
530
|
+
if (dig) parts.push(dig)
|
|
467
531
|
if (tasks.length) parts.push(`approved work queued for you:\n${tasks.map(fmtTask).join('\n')}`)
|
|
468
532
|
return text(parts.join('\n\n'))
|
|
469
533
|
}
|
|
@@ -500,10 +564,10 @@ async function dispatch(req) {
|
|
|
500
564
|
const mentionBlock = (r) => (r.mentions && r.mentions.length)
|
|
501
565
|
? `${r.priority === 'stop' ? '🛑 STOP — handle this BEFORE anything else' : '📣 ADDRESSED TO YOU'}:\n${r.mentions.map(formatMessage).join('\n')}`
|
|
502
566
|
: ''
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
567
|
+
// Both delivery paths go through the SAME digest. They used to format ambient traffic
|
|
568
|
+
// separately, which is how two copies of a rule drift apart -- the exact hazard that
|
|
569
|
+
// already bit the duplicated reconcile() and the two HIVE_RULES copies.
|
|
570
|
+
const ambientBlock = (r) => ambientDigest((r.messages || []).filter((m) => !mentionsName(m.text, me)))
|
|
507
571
|
|
|
508
572
|
if (r && r.paused) {
|
|
509
573
|
const parts = [mentionBlock(r), ambientBlock(r)].filter(Boolean)
|
package/icr.js
CHANGED
|
@@ -546,6 +546,11 @@ function spliceUnits(baseText, baseUnits, order, textByKey, opts = {}) {
|
|
|
546
546
|
out += baseText.slice(cursor, to)
|
|
547
547
|
}
|
|
548
548
|
|
|
549
|
+
// A KEY REPEATED IN `order` USED TO EMIT ITS UNIT AGAIN. `order` is assembled from both
|
|
550
|
+
// sides' unit lists, so any key present in both can appear twice, and each appearance
|
|
551
|
+
// wrote the declaration out. On a whole-file rewrite that is every declaration in the
|
|
552
|
+
// file, which is how a merge silently doubled a source file instead of merging it.
|
|
553
|
+
// Emit each unit exactly once, at its first ordered position.
|
|
549
554
|
const seenKeys = new Set()
|
|
550
555
|
for (const k of order) {
|
|
551
556
|
if (seenKeys.has(k)) continue
|
|
@@ -556,6 +561,7 @@ function spliceUnits(baseText, baseUnits, order, textByKey, opts = {}) {
|
|
|
556
561
|
emitGap(pos, u.start)
|
|
557
562
|
pos = u.end
|
|
558
563
|
}
|
|
564
|
+
// A missing key used to concatenate the string "undefined" into the source.
|
|
559
565
|
const val = textByKey.get(k)
|
|
560
566
|
if (val != null) out += val
|
|
561
567
|
} else {
|