hivecode-mcp 0.11.1 → 0.11.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.
- package/docs/HIVE_RULES.md +41 -0
- package/docs/MCP.md +77 -0
- package/grm-link.cjs +141 -0
- package/hive-mailbox.js +143 -0
- package/hive-mcp.js +54 -2
- 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 +28 -8
- package/sync.js +1982 -1699
|
@@ -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-mailbox.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
}
|
package/hive-mcp.js
CHANGED
|
@@ -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) {
|
|
@@ -456,7 +488,27 @@ async function dispatch(req) {
|
|
|
456
488
|
if (!got.unreadCount && !tasks.length) return text('✓ nothing waiting — nobody is calling you and you have no approved work. Carry on.')
|
|
457
489
|
const parts = []
|
|
458
490
|
if (got.priority === 'stop') {
|
|
491
|
+
// HANDLING URGENT MAIL COUNTS AS LIVENESS.
|
|
492
|
+
//
|
|
493
|
+
// Claims evaporate after 5 minutes so a crashed agent never deadlocks the room.
|
|
494
|
+
// But an agent that is preempted -- told to stop and deal with something else --
|
|
495
|
+
// is not crashed, it is obeying. Without this refresh it loses the file it was
|
|
496
|
+
// halfway through editing WHILE DOING WHAT IT WAS ASKED, another agent takes the
|
|
497
|
+
// claim, and it resumes writing into a region it no longer owns. The agent gets
|
|
498
|
+
// punished for complying, and the resulting clobber looks like a merge bug.
|
|
499
|
+
//
|
|
500
|
+
// Re-claiming a region you already hold is safe: acquire() only defers when a
|
|
501
|
+
// DIFFERENT owner holds it, so this refreshes `at` and never steals.
|
|
502
|
+
//
|
|
503
|
+
// One refresh buys another full TTL. A long interruption stays safe because the
|
|
504
|
+
// rules have the agent calling hive_check between steps of the urgent work too.
|
|
505
|
+
try {
|
|
506
|
+
for (const c of s.hive.claimsBoard()) {
|
|
507
|
+
if (c.by === s.name) s.hive.claim(c.region, c.intent || 'edit')
|
|
508
|
+
}
|
|
509
|
+
} catch { /* delivering the urgent message matters more than the refresh */ }
|
|
459
510
|
parts.push('🛑 URGENT — someone needs you to STOP what you are doing and handle this FIRST:')
|
|
511
|
+
parts.push('(your claims were just refreshed — the files you hold stay yours while you deal with this)')
|
|
460
512
|
parts.push(got.mentions.filter((m) => priorityOf(m.text) === 'stop').map(formatMessage).join('\n'))
|
|
461
513
|
} else if (got.mentioned) {
|
|
462
514
|
parts.push(`📣 ${got.mentions.length} message(s) addressed to YOU — answer at your next safe stopping point:`)
|
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 {
|
package/lang-brace.js
CHANGED
|
@@ -31,6 +31,10 @@ function scannerAt(src, strings) {
|
|
|
31
31
|
|
|
32
32
|
const isWS = (c) => c === ' ' || c === '\t' || c === '\r' || c === '\n'
|
|
33
33
|
|
|
34
|
+
// Tokens that CONTINUE a declaration onto the next line and can never begin a new top-level
|
|
35
|
+
// one. Used by topUnits to avoid cutting a declaration in half at a newline.
|
|
36
|
+
const CONT = /^(?:__attribute__|__asm__|__asm|__declspec|noexcept|throw\s*\(|->)/
|
|
37
|
+
|
|
34
38
|
// Extract top-level units [{start,end}] from src[from..to]. A unit is a maximal
|
|
35
39
|
// declaration/statement at bracket depth 0: either a block (signature + balanced
|
|
36
40
|
// {…}) or a statement ending at ';' or end-of-line.
|
|
@@ -52,7 +56,36 @@ function topUnits(src, strings, from = 0, to = src.length) {
|
|
|
52
56
|
if (CLOSE[c]) { depth--; i++; if (depth <= 0 && sawBrace) { while (i < to && (src[i] === ' ' || src[i] === '\t')) i++; if (src[i] === ';') i++; break } continue }
|
|
53
57
|
if (depth === 0) {
|
|
54
58
|
if (c === ';') { i++; break }
|
|
55
|
-
if (c === '\n' && !sawBrace)
|
|
59
|
+
if (c === '\n' && !sawBrace) {
|
|
60
|
+
// K&R / Allman / kernel style: the body's opening brace sits on the NEXT line, so
|
|
61
|
+
// this newline is INSIDE a declaration, not the end of one. Breaking here split
|
|
62
|
+
// every such function into two units — `stmt:<signature>` and `stmt:{` — with
|
|
63
|
+
// POSITIONAL keys, so any insertion elsewhere shifted the indices and re-paired
|
|
64
|
+
// body #N with signature #N+1. That silently produced code whose function bodies
|
|
65
|
+
// belonged to other functions, while braces stayed balanced so the parse gate
|
|
66
|
+
// passed. Measured at ~34% of real C merges before this fix.
|
|
67
|
+
// Look ahead past whitespace and comments: if a block follows, it belongs to us.
|
|
68
|
+
let k = i + 1
|
|
69
|
+
for (;;) {
|
|
70
|
+
while (k < to && isWS(src[k])) k++
|
|
71
|
+
const s2 = at(k)
|
|
72
|
+
if (s2 > k && (src[k] === '/' || src[k] === '#')) { k = s2; continue } // comment
|
|
73
|
+
break
|
|
74
|
+
}
|
|
75
|
+
if (k < to && src[k] === '{') { i++; continue } // keep scanning; the block joins this unit
|
|
76
|
+
// A declaration can also continue onto the next line WITHOUT a brace, e.g.
|
|
77
|
+
// void addReplyErrorFormat(client *c, const char *fmt, ...)
|
|
78
|
+
// __attribute__((format(printf, 2, 3)));
|
|
79
|
+
// Splitting there produced an orphan `__attribute__…;` unit whose text is IDENTICAL
|
|
80
|
+
// across many prototypes in a header, so the keys became positional (#2, #3, …) and
|
|
81
|
+
// shifted exactly like the signature/body case — orphaning attributes onto the wrong
|
|
82
|
+
// prototypes. Only tokens that can never BEGIN a new top-level declaration are listed
|
|
83
|
+
// here: `const`/`override`/`final` are deliberately excluded because in TS/Rust they
|
|
84
|
+
// legitimately start the next statement (the C++ uses of those end in `{`, which the
|
|
85
|
+
// brace lookahead above already handles).
|
|
86
|
+
if (k < to && CONT.test(src.slice(k, k + 16))) { i++; continue }
|
|
87
|
+
break
|
|
88
|
+
}
|
|
56
89
|
}
|
|
57
90
|
i++
|
|
58
91
|
}
|
|
@@ -67,15 +100,33 @@ const VARD = /\b(const|let|var|val|static|final)\s+([A-Za-z_$][\w$]*)/
|
|
|
67
100
|
// void main(String[] a)`, `std::string name()`): optional decorators + any number of
|
|
68
101
|
// modifiers + an optional return-type token, then NAME(args){. The captured name is
|
|
69
102
|
// the identifier directly before `(`.
|
|
70
|
-
|
|
103
|
+
// The return-type group ends with `[\s*&]+` rather than `\s+` so a pointer return type whose
|
|
104
|
+
// star is attached to the NAME still parses: `struct sk_buff *alloc_skb(int n)` must key as
|
|
105
|
+
// `alloc_skb`, not fall through to DECL and key as the return type `sk_buff`.
|
|
106
|
+
// The `Class::` group makes C++ out-of-line member definitions keyable — `int Widget::render(…)`
|
|
107
|
+
// keys as `render`. Without it every C++ method definition fell through to an unkeyed `stmt:`.
|
|
108
|
+
const METH = /^(?:@[\w.()]+\s+)*(?:(?:public|private|protected|internal|static|async|final|override|open|pub|suspend|inline|virtual|export|default|abstract|sealed|unsafe|extern|synchronized|native|constexpr)\s+)*(?:(?:function|fn|func|fun)\s+)?(?:[A-Za-z_$][\w$<>\[\].,:&*\s]*?[\s*&]+)?(?:[A-Za-z_$][\w$]*\s*::\s*)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\s*\([^;{]*\)(?:\s*(?:const|noexcept|override|final|volatile|mutable|&&?|throw\s*\([^){]*\)))*\s*(?:->[^{]+|:[^={]+)?\s*\{/
|
|
71
109
|
// control-flow headers also look like `name (…) {` — never key them as methods
|
|
72
110
|
const CTRL = new Set(['if', 'for', 'while', 'switch', 'catch', 'do', 'else', 'try', 'return', 'new', 'throw', 'match', 'select', 'when', 'until', 'lock', 'using', 'foreach', 'with'])
|
|
73
111
|
|
|
74
112
|
function keyOf(text) {
|
|
75
113
|
const head = text.replace(/^\s+/, '')
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
114
|
+
// DECL and METH can both match one declaration, and which is RIGHT depends on whether they
|
|
115
|
+
// agree on the name:
|
|
116
|
+
// `function a() {` -> both say "a" -> keep DECL's richer `function:a`
|
|
117
|
+
// `struct sk_buff *alloc_skb(` -> DECL says "sk_buff",
|
|
118
|
+
// METH says "alloc_skb" -> DECL matched the RETURN TYPE,
|
|
119
|
+
// so METH wins and the key names the function.
|
|
120
|
+
// Preferring DECL unconditionally (the original order) meant every pointer-returning C
|
|
121
|
+
// function was keyed by its return type, feeding the rename/dangling logic wrong names.
|
|
122
|
+
const meth = head.match(METH)
|
|
123
|
+
const decl = head.match(DECL)
|
|
124
|
+
if (meth && !CTRL.has(meth[1])) {
|
|
125
|
+
if (decl && decl[2] === meth[1]) return decl[1] + ':' + decl[2]
|
|
126
|
+
return 'method:' + meth[1]
|
|
127
|
+
}
|
|
128
|
+
if (decl) return decl[1] + ':' + decl[2]
|
|
129
|
+
const m = head.match(VARD); if (m) return 'var:' + m[2]
|
|
79
130
|
return 'stmt:' + head.split('\n')[0].trim().replace(/\s+/g, ' ')
|
|
80
131
|
}
|
|
81
132
|
|
|
@@ -130,16 +181,30 @@ function isRefToken(src, t) {
|
|
|
130
181
|
function balanced(src, strings) {
|
|
131
182
|
const at = scannerAt(src, strings)
|
|
132
183
|
const stack = []
|
|
184
|
+
let cpp = 0 // #if/#ifdef/#ifndef nesting depth
|
|
185
|
+
let lineStart = true // only whitespace seen so far on this line
|
|
133
186
|
let i = 0
|
|
134
187
|
while (i < src.length) {
|
|
135
188
|
const s = at(i)
|
|
136
|
-
if (s > i) { if (s > src.length) return false; i = s; continue }
|
|
189
|
+
if (s > i) { if (s > src.length) return false; i = s; lineStart = false; continue }
|
|
137
190
|
const c = src[i]
|
|
138
191
|
if (OPEN[c]) stack.push(c)
|
|
139
192
|
else if (CLOSE[c]) { if (stack.pop() !== CLOSE[c]) return false }
|
|
193
|
+
// PREPROCESSOR NESTING. Brace balance alone cannot see a dropped `#endif`: the merged
|
|
194
|
+
// text stays perfectly brace-balanced while the conditional structure is broken, so the
|
|
195
|
+
// gate passed and invalid C shipped as clean. Directives are matched only at the start
|
|
196
|
+
// of a line, so `#[derive]`, `#region` and `#` inside strings/comments are unaffected
|
|
197
|
+
// (strings and comments are skipped by the scanner above).
|
|
198
|
+
else if (c === '#' && lineStart) {
|
|
199
|
+
const rest = src.slice(i + 1, i + 12).replace(/^\s+/, '')
|
|
200
|
+
if (/^(ifdef|ifndef|if)\b/.test(rest)) cpp++
|
|
201
|
+
else if (/^endif\b/.test(rest)) { cpp--; if (cpp < 0) return false }
|
|
202
|
+
}
|
|
203
|
+
if (c === '\n') lineStart = true
|
|
204
|
+
else if (c !== ' ' && c !== '\t' && c !== '\r') lineStart = false
|
|
140
205
|
i++
|
|
141
206
|
}
|
|
142
|
-
return stack.length === 0
|
|
207
|
+
return stack.length === 0 && cpp === 0
|
|
143
208
|
}
|
|
144
209
|
|
|
145
210
|
export function makeBraceLanguage({ id, exts, strings = ['"', "'", '`'] }) {
|