hivecode-mcp 0.11.0 → 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 +86 -5
- 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 +27 -7
- package/sync.js +1982 -1508
|
@@ -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
|
|
@@ -399,7 +418,27 @@ function enforceDcoLock(opts = {}) {
|
|
|
399
418
|
// stated directly above this line the version MUST move: a 0.9.0 room and a 0.10.0 room disagree
|
|
400
419
|
// about how a message reaches a working agent, and that is exactly the mixed-build condition that
|
|
401
420
|
// breaks rooms. Two builds that behave differently must never share a number.
|
|
402
|
-
|
|
421
|
+
// 0.11.1 -- what this client SENDS to the relay changed: sync.js now passes `name` and `kind`
|
|
422
|
+
// as connection params so the relay can record who is in a room and how many at once (it
|
|
423
|
+
// previously stamped every connection `anon`/`unknown` and could not tell an agent from a
|
|
424
|
+
// person). By the rule above that is a delivery-behaviour change, and it is the kind that
|
|
425
|
+
// matters most here: an 0.10.0 client in a room with an 0.11.1 one is invisible in the audit
|
|
426
|
+
// log while its roommate is named, so the room's own membership record disagrees with itself.
|
|
427
|
+
// Also carries the ICR fix that stopped a whole-file rewrite duplicating every declaration.
|
|
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
|
+
})()
|
|
403
442
|
const server = new Server({ name: 'hivecode', version: VERSION }, { capabilities: { tools: {} } })
|
|
404
443
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
|
|
405
444
|
async function dispatch(req) {
|
|
@@ -449,7 +488,27 @@ async function dispatch(req) {
|
|
|
449
488
|
if (!got.unreadCount && !tasks.length) return text('✓ nothing waiting — nobody is calling you and you have no approved work. Carry on.')
|
|
450
489
|
const parts = []
|
|
451
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 */ }
|
|
452
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)')
|
|
453
512
|
parts.push(got.mentions.filter((m) => priorityOf(m.text) === 'stop').map(formatMessage).join('\n'))
|
|
454
513
|
} else if (got.mentioned) {
|
|
455
514
|
parts.push(`📣 ${got.mentions.length} message(s) addressed to YOU — answer at your next safe stopping point:`)
|
|
@@ -473,6 +532,18 @@ async function dispatch(req) {
|
|
|
473
532
|
const r = await waitForWork(ms, { wakeOn: args.wakeOn })
|
|
474
533
|
const globalState = requireSession().hive.getState()
|
|
475
534
|
const stateStr = Object.keys(globalState).length ? `\n[GLOBAL PROJECT STATE]\n${JSON.stringify(globalState, null, 2)}\n` : ''
|
|
535
|
+
// Connection state, reported ONLY when it is bad. An agent that cannot tell a
|
|
536
|
+
// quiet room from a dead socket reaches for hive_join -- which calls stop() and
|
|
537
|
+
// tears the session down, turning a blip that would have healed itself into a
|
|
538
|
+
// real detach, and running the rejoin path that can overwrite local edits. The
|
|
539
|
+
// provider reconnects on its own with backoff, so the correct action is to do
|
|
540
|
+
// nothing; the agent just has to be told that, in the one place it is looking.
|
|
541
|
+
const conn = (() => {
|
|
542
|
+
try { return requireSession().hive.status() } catch { return null }
|
|
543
|
+
})()
|
|
544
|
+
const connStr = (conn && !conn.connected)
|
|
545
|
+
? `\n[CONNECTION] ${conn.connecting ? 'reconnecting' : 'offline'} — this heals itself, the client retries automatically. Do NOT call hive_join; that tears down the session and can overwrite your local edits. Keep calling hive_wait.\n`
|
|
546
|
+
: ''
|
|
476
547
|
|
|
477
548
|
const me = requireSession().name
|
|
478
549
|
// Direct address is separated from ambient traffic on every path. It used to be that
|
|
@@ -494,13 +565,13 @@ async function dispatch(req) {
|
|
|
494
565
|
}
|
|
495
566
|
return text(`⏸ You are PAUSED by mission control. Stop new work and wait. You will be woken if someone @mentions you or assigns you a task; otherwise just call hive_wait again.${stateStr}`)
|
|
496
567
|
}
|
|
497
|
-
if (!r) return text(`(nothing yet — no approved work or new messages in the wait window. Call hive_wait again to keep waiting.)${stateStr}`)
|
|
568
|
+
if (!r) return text(`(nothing yet — no approved work or new messages in the wait window. Call hive_wait again to keep waiting.)${connStr}${stateStr}`)
|
|
498
569
|
const parts = []
|
|
499
570
|
if (r.resumed) parts.push('▶ You were RESUMED by mission control — continue.')
|
|
500
571
|
const mb = mentionBlock(r); if (mb) parts.push(mb)
|
|
501
572
|
if (r.tasks.length) parts.push(`APPROVED WORK — do these now, then hive_complete each:\n${r.tasks.map(fmtTask).join('\n')}`)
|
|
502
573
|
const ab = ambientBlock(r); if (ab) parts.push(ab)
|
|
503
|
-
return text(`${stateStr}\n` + (parts.join('\n\n') || '(resumed — no queued work yet. Call hive_wait again.)'))
|
|
574
|
+
return text(`${connStr}${stateStr}\n` + (parts.join('\n\n') || '(resumed — no queued work yet. Call hive_wait again.)'))
|
|
504
575
|
}
|
|
505
576
|
case 'hive_set_state': { requireSession().hive.setState(String(args.key), String(args.val)); return text(`Set global state [${args.key}] to [${args.val}]`) }
|
|
506
577
|
case 'hive_read_state': { return text(requireSession().hive.getState()) }
|
|
@@ -569,7 +640,17 @@ async function dispatch(req) {
|
|
|
569
640
|
case 'hive_members': { const now = Date.now(); return text(requireSession().hive.members().map((m) => `${m.name} (${m.kind})${m.editing && now - m.editing.at < 15000 ? ` — editing ${m.editing.file}` : ''}`).join('\n') || '(none)') }
|
|
570
641
|
case 'hive_status': {
|
|
571
642
|
const s = requireSession()
|
|
572
|
-
|
|
643
|
+
// Connection state belongs here too, not only in hive_wait: this is the tool an
|
|
644
|
+
// agent reaches for when it suspects something is wrong, and the answer it needs
|
|
645
|
+
// is almost always "you are still connected, do nothing".
|
|
646
|
+
let conn = null
|
|
647
|
+
try { conn = s.hive.status() } catch { }
|
|
648
|
+
return text({
|
|
649
|
+
room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION,
|
|
650
|
+
unread: mailbox().peek(chatArray()).unreadCount,
|
|
651
|
+
connected: conn ? conn.connected : null,
|
|
652
|
+
filesSynced: conn ? `${conn.filesSynced}/${conn.files}` : null,
|
|
653
|
+
})
|
|
573
654
|
}
|
|
574
655
|
case 'hive_leave': { requireSession().hive.stop(); session = null; try { fs.rmSync(SESSION_FILE) } catch {}; return text('left the room') }
|
|
575
656
|
default: return err(`unknown tool: ${name}`)
|
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 {
|