hivecode-mcp 0.8.0 → 0.11.1
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/README.md +116 -68
- package/core.js +62 -13
- package/hive-mcp.js +489 -50
- package/icr.js +502 -35
- package/lang-brace.js +240 -174
- package/lang-js.js +642 -418
- package/lang-json.js +184 -0
- package/lang-python.js +360 -149
- package/lang-ruby.js +230 -0
- package/lang-toml.js +136 -0
- package/lang-yaml.js +151 -0
- package/package.json +97 -70
- package/sync.js +413 -12
- package/MCP.md +0 -77
package/hive-mcp.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import fs from 'fs'
|
|
23
23
|
import path from 'path'
|
|
24
24
|
import crypto from 'crypto'
|
|
25
|
+
import { fileURLToPath } from 'url'
|
|
25
26
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
26
27
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
27
28
|
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
@@ -33,9 +34,10 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot
|
|
|
33
34
|
if (!process.env.HIVE_PROVENANCE) process.env.HIVE_PROVENANCE = 'on'
|
|
34
35
|
if (!process.env.HIVE_FORK_GATE) process.env.HIVE_FORK_GATE = 'on'
|
|
35
36
|
import { startSync, parseLink } from './sync.js'
|
|
37
|
+
import { createMailbox, formatMessage, mentionsName, priorityOf } from './hive-mailbox.js'
|
|
36
38
|
|
|
37
39
|
const DEFAULT_RELAY = process.env.HIVE_RELAY || 'wss://livecode-xoss.onrender.com'
|
|
38
|
-
let session = null // { hive, room, relay, dir, name }
|
|
40
|
+
let session = null // { hive, room, relay, dir, name, mailbox }
|
|
39
41
|
|
|
40
42
|
const text = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s, null, 2) }] })
|
|
41
43
|
const err = (s) => ({ content: [{ type: 'text', text: s }], isError: true })
|
|
@@ -53,18 +55,30 @@ function resolveRoom(link, dir) {
|
|
|
53
55
|
// its own MCP process from its own directory) resumes only its own identity. Lets the
|
|
54
56
|
// session survive MCP host restarts, which otherwise silently drop the agent from the room.
|
|
55
57
|
const SESSION_FILE = path.join(process.cwd(), '.hive-mcp-session.json')
|
|
56
|
-
function persistSession({ dir, name, owner }) {
|
|
57
|
-
try {
|
|
58
|
+
function persistSession({ dir, name, owner, cursor }) {
|
|
59
|
+
try {
|
|
60
|
+
const prev = readSessionFile() || {}
|
|
61
|
+
const chatCursor = cursor === undefined ? prev.chatCursor : cursor
|
|
62
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify({ dir, name, owner, chatCursor }))
|
|
63
|
+
} catch { /* best-effort */ }
|
|
64
|
+
}
|
|
65
|
+
function readSessionFile() {
|
|
66
|
+
try { return JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')) } catch { return null }
|
|
67
|
+
}
|
|
68
|
+
/** Save the read cursor so a host restart does not reopen the blind window it just closed. */
|
|
69
|
+
function persistCursor() {
|
|
70
|
+
if (!session || !session.mailbox) return
|
|
71
|
+
persistSession({ dir: session.dir, name: session.name, owner: session.owner, cursor: session.mailbox.cursor })
|
|
58
72
|
}
|
|
59
73
|
async function tryResumeSession() {
|
|
60
74
|
try {
|
|
61
75
|
if (!fs.existsSync(SESSION_FILE)) return
|
|
62
|
-
const s =
|
|
76
|
+
const s = readSessionFile()
|
|
63
77
|
if (s && s.dir && s.name && fs.existsSync(path.join(path.resolve(s.dir), '.hive.json'))) await joinRoom(s)
|
|
64
78
|
} catch { /* resume is best-effort; the agent can always hive_join explicitly */ }
|
|
65
79
|
}
|
|
66
80
|
|
|
67
|
-
async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '' }) {
|
|
81
|
+
async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor }) {
|
|
68
82
|
if (session) { try { session.hive.stop() } catch {} ; session = null }
|
|
69
83
|
const ROOT = path.resolve(dir)
|
|
70
84
|
fs.mkdirSync(ROOT, { recursive: true })
|
|
@@ -75,7 +89,7 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
|
|
|
75
89
|
// invitee pastes the link once; subsequent joins just read the folder config).
|
|
76
90
|
fs.writeFileSync(path.join(ROOT, '.hive.json'), JSON.stringify({ relay, room, ...(useToken ? { token: useToken } : {}) }, null, 2))
|
|
77
91
|
const hive = startSync({ relay, room, dir, name, kind: 'ai', owner, token: useToken, log: () => {} })
|
|
78
|
-
session = { hive, room, relay, dir, name }
|
|
92
|
+
session = { hive, room, relay, dir, name, owner, mailbox: null }
|
|
79
93
|
persistSession({ dir, name, owner }) // survive MCP host restarts (see tryResumeSession)
|
|
80
94
|
// wait for the first sync so members/board/rules are ready
|
|
81
95
|
await new Promise((resolve) => {
|
|
@@ -84,8 +98,17 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
|
|
|
84
98
|
hive.provider.on('sync', (s) => s && finish())
|
|
85
99
|
setTimeout(finish, 8000) // don't hang if the relay is cold
|
|
86
100
|
})
|
|
101
|
+
// The mailbox is created AFTER the first sync, so chat.length is the real room history and
|
|
102
|
+
// not 0 — otherwise a fresh joiner would be handed the entire backlog as unread mail.
|
|
103
|
+
// A resumed session carries its saved cursor forward instead, so anything posted while this
|
|
104
|
+
// process was down is still delivered rather than falling into the old blind window.
|
|
105
|
+
const chatLen = hive.doc.getArray('chat').length
|
|
106
|
+
const startAt = Number.isFinite(Number(chatCursor)) ? Number(chatCursor) : chatLen
|
|
107
|
+
session.mailbox = createMailbox({ me: name, startAt })
|
|
108
|
+
persistCursor()
|
|
109
|
+
|
|
87
110
|
const others = hive.members().filter((m) => m.name !== name).map((m) => `${m.name}(${m.kind})`)
|
|
88
|
-
|
|
111
|
+
|
|
89
112
|
// Event-Driven Wakeups: notify the MCP client the millisecond the DCO state changes
|
|
90
113
|
hive.doc.getMap('swarm_state').observe(() => {
|
|
91
114
|
try {
|
|
@@ -94,17 +117,69 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
|
|
|
94
117
|
// client might not support notifications, swallow error
|
|
95
118
|
}
|
|
96
119
|
})
|
|
97
|
-
|
|
120
|
+
|
|
121
|
+
// MAIL WAKEUPS — the other half of the same channel, and it was missing.
|
|
122
|
+
//
|
|
123
|
+
// The observer above pushes a notification the instant swarm_state changes, so a state flip
|
|
124
|
+
// reaches an agent mid-task. Chat had no such channel: it is observed only INSIDE the
|
|
125
|
+
// hive_wait handler, which means an @mention reached a working agent only if it happened to
|
|
126
|
+
// already be blocked in that call. An agent heads-down in a task is exactly the one that
|
|
127
|
+
// cannot choose to look.
|
|
128
|
+
//
|
|
129
|
+
// Measured on a real four-agent build (JARVIS, 2026-07-28/29): 345 mentions in the room log,
|
|
130
|
+
// agents repeatedly reporting they never saw the one addressed to them, and a PM that ended up
|
|
131
|
+
// writing open work to disk files and a shell watcher because the coordination channel could
|
|
132
|
+
// not be relied on to deliver. Every one of those workarounds exists to replace these lines.
|
|
133
|
+
//
|
|
134
|
+
// This is a DOORBELL, NOT A DELIVERY. It uses mailbox.peek(), so nothing is consumed and
|
|
135
|
+
// hive_wait/hive_check still hand over the full message with their cursor bookkeeping intact.
|
|
136
|
+
// Reusing the mailbox also inherits its two hard-won behaviours for free: an agent is never
|
|
137
|
+
// woken by its own hive_say, and repeat runs are collapsed instead of flooding.
|
|
138
|
+
let lastMailSignal = 0
|
|
139
|
+
hive.doc.getArray('chat').observe(() => {
|
|
140
|
+
try {
|
|
141
|
+
const s = session
|
|
142
|
+
if (!s || !s.mailbox) return
|
|
143
|
+
const m = s.mailbox.peek(hive.doc.getArray('chat').toArray())
|
|
144
|
+
// Compare with !== rather than >: after hive_wait consumes the backlog the count drops to
|
|
145
|
+
// 0, and a later single mention must still ring. `>` would go permanently silent.
|
|
146
|
+
if (!m.mentioned || m.mentions.length === lastMailSignal) return
|
|
147
|
+
lastMailSignal = m.mentions.length
|
|
148
|
+
server.notification({
|
|
149
|
+
method: 'notifications/hive_mail',
|
|
150
|
+
params: {
|
|
151
|
+
mentioned: true,
|
|
152
|
+
priority: m.priority, // 'stop' when someone marked it urgent — handle before continuing
|
|
153
|
+
count: m.mentions.length,
|
|
154
|
+
from: [...new Set(m.mentions.map((x) => x && x.by).filter(Boolean))],
|
|
155
|
+
latest: formatMessage(m.mentions[m.mentions.length - 1] || {}),
|
|
156
|
+
hint: 'call hive_wait or hive_check to read it — this notification consumed nothing',
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
} catch (e) {
|
|
160
|
+
// client might not support notifications, swallow error
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
98
164
|
// The sync engine writes HIVE_RULES.md into the folder on first sync — return it inline
|
|
99
165
|
// so the agent gets the rules with its join. (Fixes a ReferenceError: `rules` was never
|
|
100
166
|
// defined, which made every hive_join return "error: rules is not defined".)
|
|
101
167
|
let rulesText = ''
|
|
102
168
|
try { rulesText = fs.readFileSync(path.join(ROOT, 'HIVE_RULES.md'), 'utf8') } catch {}
|
|
103
169
|
if (!rulesText) rulesText = 'Read HIVE_RULES.md in this folder. In short: read chat + board before editing; CLAIM a file with hive_claim before you edit it and hive_release when done; announce intent with hive_say; prefer small patches; act only on accepted tasks; resolve <<<<<<< conflict markers rather than blindly overwriting.'
|
|
104
|
-
|
|
170
|
+
// The invite link carries the token when there is one — without it a secured room rejects
|
|
171
|
+
// whoever you hand it to, and the Control Room says the link is missing its token.
|
|
172
|
+
const inviteLink = `${relay}|${room}${useToken ? `|${useToken}` : ''}`
|
|
173
|
+
return `${mode}\nroom: ${room}\nrelay: ${relay}\nfolder: ${ROOT}\nhivecode: ${VERSION}\nothers here: ${others.join(', ') || 'none yet'}\ninvite link: ${inviteLink}\n\nANNOUNCE YOUR VERSION: say "joined on hivecode ${VERSION}" in chat. Mixed builds in one room cause data loss, and this is the only way anyone finds out.\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
|
|
105
174
|
}
|
|
106
175
|
|
|
107
176
|
function requireSession() { if (!session) throw new Error('not in a room — call hive_join first'); return session }
|
|
177
|
+
/** The read cursor for this participant. Lazily built if a session predates it. */
|
|
178
|
+
function mailbox() {
|
|
179
|
+
const s = requireSession()
|
|
180
|
+
if (!s.mailbox) s.mailbox = createMailbox({ me: s.name, startAt: s.hive.doc.getArray('chat').length })
|
|
181
|
+
return s.mailbox
|
|
182
|
+
}
|
|
108
183
|
function chatArray() { return requireSession().hive.doc.getArray('chat').toArray() }
|
|
109
184
|
function boardMap() { const b = requireSession().hive.doc.getMap('board'); return [...b.entries()].map(([file, e]) => ({ file, ...e })) }
|
|
110
185
|
function taskList() { return [...requireSession().hive.doc.getMap('tasks').values()] }
|
|
@@ -113,17 +188,23 @@ const fmtTask = (t) => `${t.id} [${t.status}] ${t.by} -> ${t.to}: ${t.text}${t.d
|
|
|
113
188
|
// Block until there is approved work for me (or a new chat message), then return.
|
|
114
189
|
// This is how the agent reacts the INSTANT the owner approves — no polling loop,
|
|
115
190
|
// no interval to tune. Resolves immediately if approved work already exists.
|
|
116
|
-
function waitForWork(timeoutMs) {
|
|
191
|
+
function waitForWork(timeoutMs, opts = {}) {
|
|
117
192
|
const s = requireSession()
|
|
118
193
|
const tmap = s.hive.doc.getMap('tasks')
|
|
119
194
|
const chat = s.hive.doc.getArray('chat')
|
|
120
195
|
const controls = s.hive.doc.getMap('controls')
|
|
121
196
|
const me = s.name
|
|
197
|
+
const box = mailbox()
|
|
122
198
|
const paused = () => { const c = controls.get(me); return !!(c && c.state === 'paused') }
|
|
123
199
|
const accepted = () => (paused() ? [] : [...tmap.values()].filter((t) => t.to === me && t.status === 'accepted'))
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
200
|
+
// Deliver through the mailbox on EVERY return path. The old code captured a call-scoped
|
|
201
|
+
// `baseChatLen` here and returned `chat.slice(baseChatLen)`, which meant anything posted
|
|
202
|
+
// between one wait returning and the next one starting — the whole time the agent was
|
|
203
|
+
// working — was returned by no path at all. Two paths also hardcoded `newChat: []`.
|
|
204
|
+
const deliver = (extra) => { const got = box.take(chat.toArray()); persistCursor(); return { ...got, ...extra } }
|
|
205
|
+
// Only wake for ambient chatter if asked to. Defaults to waking on anything, as before.
|
|
206
|
+
const wakeOnMentionOnly = opts.wakeOn === 'mention'
|
|
207
|
+
const mentionsMe = (t) => mentionsName(t, me)
|
|
127
208
|
// PAUSED: stop auto-working, but STAY LISTENING. A paused agent doesn't pull its
|
|
128
209
|
// queued work — yet it still wakes the moment it's directly addressed: a new
|
|
129
210
|
// @mention in chat, or a new task assigned to it. It handles that, then calls
|
|
@@ -133,28 +214,47 @@ function waitForWork(timeoutMs) {
|
|
|
133
214
|
return new Promise((resolve) => {
|
|
134
215
|
let done = false
|
|
135
216
|
const finish = (v) => { if (!done) { done = true; controls.unobserve(onCtl); chat.unobserve(onChat); tmap.unobserve(onTask); resolve(v) } }
|
|
136
|
-
const mentions = () => chat.toArray().slice(baseChatLen).filter((m) => mentionsMe(m.text))
|
|
137
217
|
const directTasks = () => [...tmap.values()].filter((t) => t.to === me && t.status === 'accepted' && !baseTaskIds.has(t.id))
|
|
138
218
|
const check = () => {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (
|
|
219
|
+
// Resumed: hand over the backlog too. This path returned `newChat: []`, so an agent
|
|
220
|
+
// came back from a pause having silently lost everything said while it was paused.
|
|
221
|
+
if (!paused()) return finish(deliver({ tasks: accepted(), resumed: true }))
|
|
222
|
+
const dt = directTasks()
|
|
223
|
+
if (dt.length) return finish(deliver({ paused: true, mentioned: true, tasks: dt }))
|
|
224
|
+
// A paused agent stays deaf to chatter but wakes when directly addressed. Unchanged,
|
|
225
|
+
// except the messages come from the mailbox now, so none are skipped.
|
|
226
|
+
if (box.peek(chat.toArray()).mentioned) finish(deliver({ paused: true, tasks: [] }))
|
|
142
227
|
}
|
|
143
228
|
const onCtl = check, onChat = check, onTask = check
|
|
144
229
|
controls.observe(onCtl); chat.observe(onChat); tmap.observe(onTask)
|
|
145
230
|
setTimeout(() => finish({ paused: true }), timeoutMs)
|
|
146
231
|
})
|
|
147
232
|
}
|
|
233
|
+
// Queued work no longer hides the mail. This returned `newChat: []` outright, which made
|
|
234
|
+
// the busiest agent in the room the one guaranteed to see no messages at all.
|
|
148
235
|
const ready = accepted()
|
|
149
|
-
if (ready.length) return Promise.resolve({ tasks: ready
|
|
236
|
+
if (ready.length) return Promise.resolve(deliver({ tasks: ready }))
|
|
237
|
+
// Anything already unread is handed over immediately rather than waited past — that backlog
|
|
238
|
+
// is exactly what used to be unreachable.
|
|
239
|
+
if (box.hasUnread(chat.toArray())) {
|
|
240
|
+
const pending = box.peek(chat.toArray())
|
|
241
|
+
if (!wakeOnMentionOnly || pending.mentioned) return Promise.resolve(deliver({ tasks: [] }))
|
|
242
|
+
}
|
|
150
243
|
return new Promise((resolve) => {
|
|
151
244
|
let done = false
|
|
152
245
|
const finish = (v) => { if (!done) { done = true; tmap.unobserve(onTask); chat.unobserve(onChat); controls.unobserve(onCtl); resolve(v) } }
|
|
153
|
-
const onTask = () => { const a = accepted(); if (a.length) finish({ tasks: a
|
|
154
|
-
const onChat = () => {
|
|
246
|
+
const onTask = () => { const a = accepted(); if (a.length) finish(deliver({ tasks: a })) }
|
|
247
|
+
const onChat = () => {
|
|
248
|
+
const seen = box.peek(chat.toArray())
|
|
249
|
+
if (!seen.unreadCount) return // only this agent's own messages — not mail
|
|
250
|
+
if (wakeOnMentionOnly && !seen.mentioned) return // ambient chatter, and the caller opted out
|
|
251
|
+
finish(deliver({ tasks: accepted() }))
|
|
252
|
+
}
|
|
155
253
|
const onCtl = () => { if (paused()) finish({ paused: true }) } // paused mid-wait -> stop handing out work
|
|
156
254
|
tmap.observe(onTask); chat.observe(onChat); controls.observe(onCtl)
|
|
157
|
-
|
|
255
|
+
// On timeout, flush anything that arrived rather than dropping it: a message must never
|
|
256
|
+
// expire unread just because the wait window closed on it.
|
|
257
|
+
setTimeout(() => finish(box.hasUnread(chat.toArray()) ? deliver({ tasks: accepted() }) : null), timeoutMs)
|
|
158
258
|
})
|
|
159
259
|
}
|
|
160
260
|
|
|
@@ -165,18 +265,20 @@ const TOOLS = [
|
|
|
165
265
|
inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name to assign to' }, text: { type: 'string', description: 'what to do' } }, required: ['to', 'text'] } },
|
|
166
266
|
{ name: 'hive_read_tasks', description: 'Read tasks. Shows tasks directed at YOU (act only on accepted ones) and all pending tasks awaiting approval.',
|
|
167
267
|
inputSchema: { type: 'object', properties: {} } },
|
|
168
|
-
{ name: 'hive_wait', description: 'BLOCK until there is approved work for you
|
|
169
|
-
inputSchema: { type: 'object', properties: { timeoutSeconds: { type: 'number', description: 'max seconds to wait (default 60, max 300)' } } } },
|
|
268
|
+
{ name: 'hive_wait', description: 'BLOCK until there is approved work for you, or unread mail arrives, then return BOTH. Use this as your main loop: call hive_wait; do the work; hive_complete; call hive_wait again. Returns instantly if approved work or unread mail already exists — nothing addressed to you can be missed while you were busy, because your read position is remembered between calls.',
|
|
269
|
+
inputSchema: { type: 'object', properties: { timeoutSeconds: { type: 'number', description: 'max seconds to wait (default 60, max 300)' }, wakeOn: { type: 'string', enum: ['any', 'mention'], description: "'any' (default) wakes on any new message; 'mention' only wakes when you are @mentioned or assigned a task — use it in a noisy room. Ambient messages are still delivered on the next wake either way, never dropped." } } } },
|
|
270
|
+
{ name: 'hive_check', description: 'NON-BLOCKING: is anyone calling me? Returns unread messages addressed to you, plus approved work, and returns immediately. Call this between edits during a long task — that is the whole point: hive_wait blocks, so it cannot be used while you are working, and this can. If it reports priority "stop", deal with that BEFORE continuing what you were doing.',
|
|
271
|
+
inputSchema: { type: 'object', properties: { peek: { type: 'boolean', description: 'true = look without marking messages read (default false: reading them here counts as read)' } } } },
|
|
170
272
|
{ name: 'hive_approve', description: 'Approve a pending task (you must be the owner of the target AI). The AI may then act on it.',
|
|
171
273
|
inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
|
|
172
274
|
{ name: 'hive_deny', description: 'Deny a pending task (you must be the owner of the target AI).',
|
|
173
275
|
inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
|
|
174
276
|
{ name: 'hive_complete', description: 'Mark a task you were doing as done (optionally with a note).',
|
|
175
277
|
inputSchema: { type: 'object', properties: { id: { type: 'string' }, note: { type: 'string' } }, required: ['id'] } },
|
|
176
|
-
{ name: 'hive_say', description: 'Post a coordination message to the room chat. Use this to announce what you are about to work on BEFORE editing (e.g. "taking auth.js: adding login").',
|
|
177
|
-
inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'the message' } }, required: ['text'] } },
|
|
178
|
-
{ name: 'hive_read_chat', description: 'Read the room conversation. Read this to see what humans and other agents are doing before and while you work.',
|
|
179
|
-
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'how many recent messages (default 30)' } } } },
|
|
278
|
+
{ name: 'hive_say', description: 'Post a coordination message to the room chat. Use this to announce what you are about to work on BEFORE editing (e.g. "taking auth.js: adding login"). Address someone directly with @Name — that is what makes it land as mail rather than ambient chatter. Set urgent:true when you need them to stop what they are doing.',
|
|
279
|
+
inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'the message' }, urgent: { type: 'boolean', description: 'true = prefix [!] so the recipient treats it as "handle before continuing", not "read at your next boundary"' } }, required: ['text'] } },
|
|
280
|
+
{ name: 'hive_read_chat', description: 'Read the room conversation. Read this to see what humans and other agents are doing before and while you work. Use mentioningMe to recover only what was addressed to you.',
|
|
281
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'how many recent messages (default 30)' }, mentioningMe: { type: 'boolean', description: 'true = only messages that @mention you, across the whole room history — use this to recover your own backlog' }, unreadOnly: { type: 'boolean', description: 'true = only what you have not been handed yet (does not mark it read)' } } } },
|
|
180
282
|
{ name: 'hive_read_board', description: 'Read recent whole-file rewrites (who rewrote what, which symbols). Read this before editing a file — if it was just rewritten, re-read the file first.',
|
|
181
283
|
inputSchema: { type: 'object', properties: {} } },
|
|
182
284
|
{ name: 'hive_claim', description: 'CLAIM a file (or region) BEFORE you edit it, so other agents flow around you instead of colliding. Returns whether you got it — if false, someone else holds it: work on something else. Claims auto-expire, so call hive_release when done (or it frees itself).',
|
|
@@ -185,25 +287,129 @@ const TOOLS = [
|
|
|
185
287
|
inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'the file path (or region id) to release' } }, required: ['region'] } },
|
|
186
288
|
{ name: 'hive_claims', description: 'See what every agent is currently working on (the live claim board). Read this to pick open work and avoid taken regions.',
|
|
187
289
|
inputSchema: { type: 'object', properties: {} } },
|
|
290
|
+
{ name: 'hive_edit', description: 'Write a file THROUGH the medium (preferred over editing on disk): your intent and exact change are cryptographically stamped into the provenance ledger at the instant of writing — race-free — then merged and synced like any edit. Claim the file first with hive_claim; pass your intent here too so the WHY travels with the change.',
|
|
291
|
+
inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'relative file path in the room' }, content: { type: 'string', description: 'the complete new file content' }, intent: { type: 'string', description: 'WHY you are making this change (signed into provenance)' } }, required: ['file', 'content'] } },
|
|
292
|
+
{ name: 'hive_fork', description: 'Read an active SILENT FORK on a file as a resolvable object: the common base plus EVERY divergent version with its author and cryptographically signed intent (the WHY). Call this when chat announces a fork, then reconcile both intents and submit via hive_resolve.',
|
|
293
|
+
inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'the forked file path (as announced in chat)' } }, required: ['file'] } },
|
|
294
|
+
{ name: 'hive_resolve', description: 'Resolve an active fork by submitting the FULL reconciled file content — code that serves EVERY side\'s signed intent (read them with hive_fork), not just one winner. The medium VALIDATES your proposal with ICR (must parse, no dangling references) before it lands; a broken proposal is refused and nothing changes. On success the resolution is signed by you, synced to every peer, and announced.',
|
|
295
|
+
inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'the forked file path' }, code: { type: 'string', description: 'the complete resolved file content (no <<<<<<< markers)' } }, required: ['file', 'code'] } },
|
|
188
296
|
{ name: 'hive_members', description: 'List who is currently in the room (humans and AI agents).', inputSchema: { type: 'object', properties: {} } },
|
|
189
297
|
{ name: 'hive_status', description: 'Show the current session (room, relay, folder, name).', inputSchema: { type: 'object', properties: {} } },
|
|
190
298
|
{ name: 'hive_leave', description: 'Leave the room and stop syncing.', inputSchema: { type: 'object', properties: {} } },
|
|
191
299
|
{ name: 'hive_set_state', description: 'PM ONLY: Set a global project state flag (e.g. key="contract", val="READY"). This physically unblocks/blocks other agents. WARNING: ONLY use short enum values. Do NOT use this to store paragraphs of text or code, as it will bloat the context window. Use files for text.', inputSchema: { type: 'object', properties: { key: { type: 'string' }, val: { type: 'string' } }, required: ['key', 'val'] } },
|
|
192
300
|
{ name: 'hive_read_state', description: 'Read the global project state flags.', inputSchema: { type: 'object', properties: {} } },
|
|
193
|
-
{ name: 'hive_set_schema', description: 'PM ONLY: Define strict JSON schema validation for a global state key
|
|
301
|
+
{ name: 'hive_set_schema', description: 'PM ONLY: Define strict JSON schema validation for a global state key. Beyond `enum` you can make it a real state MACHINE: `transitions` ({"READY":["IN_PROGRESS"]}) says which moves are legal at all, and `requires` ({"LIVE":{"suppression_ui":"APPROVED"}}) says what must already be true to arrive at a value — which is how you make a release gate hold by construction instead of by everyone remembering it. Example: rules={"enum":["PLANNING","READY","APPROVED"],"transitions":{"PLANNING":["READY"],"READY":["APPROVED"],"APPROVED":[]},"requires":{"APPROVED":{"tests":"GREEN"}}}', inputSchema: { type: 'object', properties: { key: { type: 'string' }, rules: { type: 'object' } }, required: ['key', 'rules'] } },
|
|
302
|
+
{ name: 'hive_attest', description: 'Record what YOUR folder actually holds for a file, so the room can tell whether peers agree. Measure it yourself (bytes and lines) and report. If another peer has attested a DIFFERENT size, everyone is warned immediately — that disagreement is the point. Use it after publishing a file, and when asked to confirm somebody else\'s publish. Two matching attestations from different peers is the only real proof a write landed; your own folder shows your own write whether or not it propagated.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'workspace-relative path' }, bytes: { type: 'number', description: 'exact byte count in YOUR folder' }, lines: { type: 'number', description: 'line count in YOUR folder' } }, required: ['file', 'bytes'] } },
|
|
303
|
+
{ name: 'hive_verified', description: 'Has a file been independently confirmed? Returns every peer\'s attested size, whether they DISAGREE, and whether quorum is reached (2+ peers, same bytes). Check this before trusting that a publish landed, and before building on a file somebody else wrote.', inputSchema: { type: 'object', properties: { file: { type: 'string' } }, required: ['file'] } },
|
|
194
304
|
]
|
|
195
305
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
306
|
+
// The state machine's enforcement point. Three separate gates, and each one exists because a rule
|
|
307
|
+
// written in prose was read by four capable agents and then broken anyway (JARVIS, 2026-07-29).
|
|
308
|
+
//
|
|
309
|
+
// 1. LOCKED the original gate — you may work, you may not publish
|
|
310
|
+
// 2. PUBLISH:<n> a LEASED grant: n writes, decremented by the server, auto-closes at zero
|
|
311
|
+
// 3. path:<p>=POISONED a path nobody may write, ever, for as long as the flag stands
|
|
312
|
+
//
|
|
313
|
+
// `opts.consumes` marks a tool as spending a lease (the write tools). `opts.path` is the region it
|
|
314
|
+
// would write, checked against the path gate.
|
|
315
|
+
function enforceDcoLock(opts = {}) {
|
|
316
|
+
const s = requireSession()
|
|
317
|
+
const state = s.hive.getState()
|
|
318
|
+
const name = s.name
|
|
319
|
+
const status = state[`${name}_status`]
|
|
320
|
+
|
|
321
|
+
// --- gate 3: the path itself may be untouchable, regardless of who you are -------------------
|
|
322
|
+
// Attached to the RESOURCE, not the ACTOR, and that is the new idea here. Every other guard in
|
|
323
|
+
// this system asks "may this agent write?"; none could express "this file must never be written
|
|
324
|
+
// again by anyone." That question has a real answer: a path whose delete has propagated is
|
|
325
|
+
// poisoned — writing it silently resurrects the old content over the new, or leaves two folders
|
|
326
|
+
// permanently disagreeing with no marker and no warning. Tonight that fact lived in a markdown
|
|
327
|
+
// file that agents had to remember to read, and the PM ordered a live credential route deleted
|
|
328
|
+
// anyway. A flag in the state map cannot be forgotten.
|
|
329
|
+
// FIVE VALUES, BECAUSE EACH ONE IMPLIES A DIFFERENT ACTION. A single POISONED flag was the first
|
|
330
|
+
// design and it was wrong — @Claude-Engine caught it live: `path:engine/brain/llm.js = POISONED`
|
|
331
|
+
// was FALSE in its folder, where that path held the correct 8,012-byte file while mine held a
|
|
332
|
+
// 22,312-line wreck. Marking it globally poisoned broadcast ONE FOLDER'S REALITY as room truth,
|
|
333
|
+
// inside the one channel every agent trusts. Its objection is the rule: a per-folder fact must
|
|
334
|
+
// never be stored as a global one.
|
|
335
|
+
//
|
|
336
|
+
// LIVE / OPEN default. Writable under the normal one-spaced-write rule.
|
|
337
|
+
// SUPERSEDED clean, valid code that has been REPLACED. Read freely, do not import, do
|
|
338
|
+
// not write. Distinct from corruption: calling hn.js "poisoned" would send
|
|
339
|
+
// the next agent rewriting a file that is perfectly fine.
|
|
340
|
+
// CORRUPT content unusable in every folder that has measured it. Do not read it as
|
|
341
|
+
// reference; publish the replacement to a NEW path.
|
|
342
|
+
// DIVERGED peers hold DIFFERENT bytes. Nobody writes until it is reconciled, because
|
|
343
|
+
// there is no single truth to write on top of. Use hive_attest to establish
|
|
344
|
+
// who holds what — that is what it is for.
|
|
345
|
+
// DELETED_UNWRITABLE a delete propagated. Writes here vanish or silently resurrect the old
|
|
346
|
+
// content. Never write, never delete, new path only. Globally true.
|
|
347
|
+
if (opts.path) {
|
|
348
|
+
const ps = state[`path:${String(opts.path)}`]
|
|
349
|
+
const stop = {
|
|
350
|
+
POISONED: `[Path Poisoned] ${opts.path} must NOT be written. Publish to a NEW path instead. (Legacy flag — prefer DELETED_UNWRITABLE / CORRUPT / DIVERGED, which say WHY.)`,
|
|
351
|
+
DELETED_UNWRITABLE: `[Path Unwritable] ${opts.path} had a delete propagate. A write here vanishes or silently resurrects the deleted content, and two folders end up permanently disagreeing with no marker. Never write it and never delete it again — publish to a NEW path.`,
|
|
352
|
+
CORRUPT: `[Path Corrupt] ${opts.path} is unusable in every folder that has measured it. Do not write it and do not read it as reference — build the replacement out-of-tree and publish it to a NEW path.`,
|
|
353
|
+
DIVERGED: `[Path Diverged] peers hold DIFFERENT bytes for ${opts.path}, so there is no single truth to write on top of. Call hive_attest to record what YOUR folder holds and see who disagrees. Nobody writes this path until it is reconciled.`,
|
|
354
|
+
FROZEN: `[Path Frozen] ${opts.path} is frozen by the state machine. Ask the owner to set path:${opts.path}=LIVE before writing it.`,
|
|
355
|
+
}
|
|
356
|
+
if (ps && stop[ps]) throw new Error(stop[ps])
|
|
357
|
+
// An unrecognised value fails CLOSED. A safety registry that silently ignores a flag it does not
|
|
358
|
+
// understand is worse than no registry, because agents will trust it — and a 0.9.0 client
|
|
359
|
+
// ignoring these flags entirely is already the known hole here.
|
|
360
|
+
if (ps && ps !== 'LIVE' && ps !== 'OPEN') {
|
|
361
|
+
throw new Error(`[Path Restricted] path:${opts.path} is set to "${ps}", which this build does not recognise. Refusing the write rather than guessing. Ask the room what it means, or have the owner set it to LIVE.`)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (status === 'LOCKED' || state[`${name}_locked`] === 'true') {
|
|
200
366
|
throw new Error(`[DCO Enforced] You are locked by the global state machine. Tool execution denied. Block on hive_wait until your status changes.`)
|
|
201
367
|
}
|
|
368
|
+
|
|
369
|
+
// --- gate 2: leased publication grants -------------------------------------------------------
|
|
370
|
+
// "One clean write per path" was the single most expensive lesson of the JARVIS build: a file
|
|
371
|
+
// written once landed byte-perfect in every folder (7 for 7), and a file written twice in quick
|
|
372
|
+
// succession was destroyed (333 -> 22,312 lines; 104 -> 70,881). Stated as a protocol it was
|
|
373
|
+
// violated within the hour by everyone including its author. A COUNTED lease makes the second
|
|
374
|
+
// write impossible rather than discouraged: the server spends it, and the window shuts itself.
|
|
375
|
+
const lease = /^PUBLISH(?::(\d+))?$/.exec(String(status || ''))
|
|
376
|
+
if (lease && opts.consumes) {
|
|
377
|
+
const left = lease[1] === undefined ? 1 : Number(lease[1])
|
|
378
|
+
if (left <= 0) {
|
|
379
|
+
throw new Error(`[Lease Spent] Your PUBLISH window is used up. Ask for a new one — and if this is a revision of what you just wrote, publish to a NEW path rather than rewriting it.`)
|
|
380
|
+
}
|
|
381
|
+
const next = left - 1
|
|
382
|
+
try {
|
|
383
|
+
s.hive.setState(`${name}_status`, next > 0 ? `PUBLISH:${next}` : 'LOCKED')
|
|
384
|
+
} catch (e) {
|
|
385
|
+
// A schema whose transitions forbid the auto-close must not strand the agent mid-write, and
|
|
386
|
+
// must not silently leave the window open either. Say it loudly and let the write proceed.
|
|
387
|
+
try { s.hive.say(`[!] lease for ${name} could not auto-close (${e.message}) — treat the window as SPENT and do not write again.`) } catch { }
|
|
388
|
+
}
|
|
389
|
+
}
|
|
202
390
|
}
|
|
203
391
|
|
|
204
|
-
|
|
392
|
+
// Bumped from 0.8.0 when message delivery changed (see hive-mailbox.js). Two builds with the
|
|
393
|
+
// same version number but different delivery behaviour is precisely the mixed-build room that
|
|
394
|
+
// breaks rooms — and the standing rule "report your Hivecode version when you join" cannot work
|
|
395
|
+
// if the builds are indistinguishable. So the version is reported on join and by hive_status.
|
|
396
|
+
// 0.10.0 — mail now rides out on EVERY tool result (mailBanner), the DCO gate gained leased
|
|
397
|
+
// PUBLISH:<n> grants and per-PATH poisoning, setState became a real state machine (transitions +
|
|
398
|
+
// requires), and hive_attest/hive_verified were added. Delivery behaviour changed, so by the rule
|
|
399
|
+
// stated directly above this line the version MUST move: a 0.9.0 room and a 0.10.0 room disagree
|
|
400
|
+
// about how a message reaches a working agent, and that is exactly the mixed-build condition that
|
|
401
|
+
// breaks rooms. Two builds that behave differently must never share a number.
|
|
402
|
+
// 0.11.1 -- what this client SENDS to the relay changed: sync.js now passes `name` and `kind`
|
|
403
|
+
// as connection params so the relay can record who is in a room and how many at once (it
|
|
404
|
+
// previously stamped every connection `anon`/`unknown` and could not tell an agent from a
|
|
405
|
+
// person). By the rule above that is a delivery-behaviour change, and it is the kind that
|
|
406
|
+
// matters most here: an 0.10.0 client in a room with an 0.11.1 one is invisible in the audit
|
|
407
|
+
// log while its roommate is named, so the room's own membership record disagrees with itself.
|
|
408
|
+
// Also carries the ICR fix that stopped a whole-file rewrite duplicating every declaration.
|
|
409
|
+
export const VERSION = '0.11.1'
|
|
410
|
+
const server = new Server({ name: 'hivecode', version: VERSION }, { capabilities: { tools: {} } })
|
|
205
411
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
|
|
206
|
-
|
|
412
|
+
async function dispatch(req) {
|
|
207
413
|
const { name, arguments: args = {} } = req.params
|
|
208
414
|
try {
|
|
209
415
|
// AUTO-RESUME: the MCP host may restart this server process at any time, which
|
|
@@ -213,8 +419,54 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
213
419
|
if (!session && name !== 'hive_join' && name !== 'hive_leave') await tryResumeSession()
|
|
214
420
|
switch (name) {
|
|
215
421
|
case 'hive_join': return text(await joinRoom(args))
|
|
216
|
-
case 'hive_say': {
|
|
217
|
-
|
|
422
|
+
case 'hive_say': {
|
|
423
|
+
const raw = String(args.text || '')
|
|
424
|
+
// The marker is in the text itself so it survives into HIVE_CHAT.md and is visible to
|
|
425
|
+
// a human reading the log, not just to the recipient's tool output.
|
|
426
|
+
const body = args.urgent && !/^\s*\[!\]/.test(raw) ? `[!] ${raw}` : raw
|
|
427
|
+
requireSession().hive.say(body)
|
|
428
|
+
return text(`sent: ${body}`)
|
|
429
|
+
}
|
|
430
|
+
case 'hive_read_chat': {
|
|
431
|
+
const me = requireSession().name
|
|
432
|
+
const all = chatArray()
|
|
433
|
+
if (args.mentioningMe) {
|
|
434
|
+
// Backlog recovery: the whole history, filtered to what was addressed to you. This
|
|
435
|
+
// is what the PM agent had to do by hand with grep when delivery failed.
|
|
436
|
+
const mine = all.filter((m) => mentionsName(m.text, me))
|
|
437
|
+
if (!mine.length) return text('(nothing in this room has @mentioned you)')
|
|
438
|
+
return text(`${mine.length} message(s) addressed to you:\n` + mine.map(formatMessage).join('\n'))
|
|
439
|
+
}
|
|
440
|
+
if (args.unreadOnly) {
|
|
441
|
+
const seen = mailbox().peek(all)
|
|
442
|
+
if (!seen.unreadCount) return text('(nothing unread)')
|
|
443
|
+
return text(`${seen.unreadCount} unread (still marked unread — hive_check or hive_wait will hand these over):\n` + seen.messages.map(formatMessage).join('\n'))
|
|
444
|
+
}
|
|
445
|
+
const n = args.limit || 30
|
|
446
|
+
const msgs = all.slice(-n).map((m) => `${m.at} ${m.by}(${m.kind}): ${m.text}`)
|
|
447
|
+
return text(msgs.join('\n') || '(no messages yet)')
|
|
448
|
+
}
|
|
449
|
+
case 'hive_check': {
|
|
450
|
+
const s = requireSession()
|
|
451
|
+
const all = chatArray()
|
|
452
|
+
const box = mailbox()
|
|
453
|
+
const got = args.peek ? box.peek(all) : box.take(all)
|
|
454
|
+
if (!args.peek) persistCursor()
|
|
455
|
+
const tasks = [...s.hive.doc.getMap('tasks').values()].filter((t) => t.to === s.name && t.status === 'accepted')
|
|
456
|
+
if (!got.unreadCount && !tasks.length) return text('✓ nothing waiting — nobody is calling you and you have no approved work. Carry on.')
|
|
457
|
+
const parts = []
|
|
458
|
+
if (got.priority === 'stop') {
|
|
459
|
+
parts.push('🛑 URGENT — someone needs you to STOP what you are doing and handle this FIRST:')
|
|
460
|
+
parts.push(got.mentions.filter((m) => priorityOf(m.text) === 'stop').map(formatMessage).join('\n'))
|
|
461
|
+
} else if (got.mentioned) {
|
|
462
|
+
parts.push(`📣 ${got.mentions.length} message(s) addressed to YOU — answer at your next safe stopping point:`)
|
|
463
|
+
parts.push(got.mentions.map(formatMessage).join('\n'))
|
|
464
|
+
}
|
|
465
|
+
const ambient = got.messages.filter((m) => !mentionsName(m.text, s.name))
|
|
466
|
+
if (ambient.length) parts.push(`room activity (${ambient.length}):\n` + ambient.map(formatMessage).join('\n'))
|
|
467
|
+
if (tasks.length) parts.push(`approved work queued for you:\n${tasks.map(fmtTask).join('\n')}`)
|
|
468
|
+
return text(parts.join('\n\n'))
|
|
469
|
+
}
|
|
218
470
|
case 'hive_read_board': { const b = boardMap(); return text(b.length ? b.map((e) => `${e.at} ${e.by} rewrote ${e.file} (${e.churn}) — touched: ${(e.symbols || []).join(', ')}`).join('\n') : '(no rewrites logged)') }
|
|
219
471
|
case 'hive_assign': { enforceDcoLock(); const id = requireSession().hive.assign(String(args.to || ''), String(args.text || '')); return id ? text(`assigned task ${id} to ${args.to} (pending approval if they have an owner)`) : err('need to and text') }
|
|
220
472
|
case 'hive_read_tasks': {
|
|
@@ -225,35 +477,79 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
225
477
|
}
|
|
226
478
|
case 'hive_wait': {
|
|
227
479
|
const ms = Math.min(Math.max(Number(args.timeoutSeconds) || 60, 1), 300) * 1000
|
|
228
|
-
const r = await waitForWork(ms)
|
|
480
|
+
const r = await waitForWork(ms, { wakeOn: args.wakeOn })
|
|
229
481
|
const globalState = requireSession().hive.getState()
|
|
230
482
|
const stateStr = Object.keys(globalState).length ? `\n[GLOBAL PROJECT STATE]\n${JSON.stringify(globalState, null, 2)}\n` : ''
|
|
483
|
+
// Connection state, reported ONLY when it is bad. An agent that cannot tell a
|
|
484
|
+
// quiet room from a dead socket reaches for hive_join -- which calls stop() and
|
|
485
|
+
// tears the session down, turning a blip that would have healed itself into a
|
|
486
|
+
// real detach, and running the rejoin path that can overwrite local edits. The
|
|
487
|
+
// provider reconnects on its own with backoff, so the correct action is to do
|
|
488
|
+
// nothing; the agent just has to be told that, in the one place it is looking.
|
|
489
|
+
const conn = (() => {
|
|
490
|
+
try { return requireSession().hive.status() } catch { return null }
|
|
491
|
+
})()
|
|
492
|
+
const connStr = (conn && !conn.connected)
|
|
493
|
+
? `\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`
|
|
494
|
+
: ''
|
|
231
495
|
|
|
496
|
+
const me = requireSession().name
|
|
497
|
+
// Direct address is separated from ambient traffic on every path. It used to be that
|
|
498
|
+
// only a PAUSED agent got this distinction, so a working agent could not tell "stop,
|
|
499
|
+
// wrong file" from two other agents talking to each other.
|
|
500
|
+
const mentionBlock = (r) => (r.mentions && r.mentions.length)
|
|
501
|
+
? `${r.priority === 'stop' ? '🛑 STOP — handle this BEFORE anything else' : '📣 ADDRESSED TO YOU'}:\n${r.mentions.map(formatMessage).join('\n')}`
|
|
502
|
+
: ''
|
|
503
|
+
const ambientBlock = (r) => {
|
|
504
|
+
const rest = (r.messages || []).filter((m) => !mentionsName(m.text, me))
|
|
505
|
+
return rest.length ? `room activity:\n${rest.map(formatMessage).join('\n')}` : ''
|
|
506
|
+
}
|
|
507
|
+
|
|
232
508
|
if (r && r.paused) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (r.tasks && r.tasks.length) parts.push(`📌 Directed task(s):\n${r.tasks.map(fmtTask).join('\n')}`)
|
|
509
|
+
const parts = [mentionBlock(r), ambientBlock(r)].filter(Boolean)
|
|
510
|
+
if (r.tasks && r.tasks.length) parts.push(`📌 Directed task(s):\n${r.tasks.map(fmtTask).join('\n')}`)
|
|
511
|
+
if (parts.length) {
|
|
237
512
|
return text(`⏸ You are PAUSED, but were directly addressed. Handle ONLY this, then call hive_wait again (you stay paused until a human resumes you — don't pick up other queued work):\n\n${parts.join('\n\n')}`)
|
|
238
513
|
}
|
|
239
514
|
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}`)
|
|
240
515
|
}
|
|
241
|
-
if (!r) return text(`(nothing yet — no approved work or new messages in the wait window. Call hive_wait again to keep waiting.)${stateStr}`)
|
|
516
|
+
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}`)
|
|
242
517
|
const parts = []
|
|
243
518
|
if (r.resumed) parts.push('▶ You were RESUMED by mission control — continue.')
|
|
519
|
+
const mb = mentionBlock(r); if (mb) parts.push(mb)
|
|
244
520
|
if (r.tasks.length) parts.push(`APPROVED WORK — do these now, then hive_complete each:\n${r.tasks.map(fmtTask).join('\n')}`)
|
|
245
|
-
|
|
246
|
-
return text(`${stateStr}\n` + (parts.join('\n\n') || '(resumed — no queued work yet. Call hive_wait again.)'))
|
|
521
|
+
const ab = ambientBlock(r); if (ab) parts.push(ab)
|
|
522
|
+
return text(`${connStr}${stateStr}\n` + (parts.join('\n\n') || '(resumed — no queued work yet. Call hive_wait again.)'))
|
|
247
523
|
}
|
|
248
524
|
case 'hive_set_state': { requireSession().hive.setState(String(args.key), String(args.val)); return text(`Set global state [${args.key}] to [${args.val}]`) }
|
|
249
525
|
case 'hive_read_state': { return text(requireSession().hive.getState()) }
|
|
250
526
|
case 'hive_set_schema': { requireSession().hive.setSchema(String(args.key), args.rules); return text(`Set schema validation for [${args.key}]`) }
|
|
527
|
+
case 'hive_attest': {
|
|
528
|
+
const f = String(args.file || ''); if (!f) return err('need a file path')
|
|
529
|
+
if (!Number.isFinite(Number(args.bytes))) return err('need bytes — measure your own folder, do not estimate')
|
|
530
|
+
const r = requireSession().hive.attest(f, Number(args.bytes), Number(args.lines || 0))
|
|
531
|
+
const who = Object.entries(r.peers).map(([n, v]) => `${n}: ${v.bytes} bytes / ${v.lines} lines`).join('\n ')
|
|
532
|
+
if (r.diverged) {
|
|
533
|
+
return text(`[!] DIVERGENCE on ${f} — peers do NOT agree:\n ${who}\n\nThe room has no single truth for this path. Nobody should write it. If it cannot be reconciled, have the owner set path:${f}=POISONED and publish the correct content to a NEW path.`)
|
|
534
|
+
}
|
|
535
|
+
return text(`attested ${f} at ${args.bytes} bytes.\n ${who}\n${r.count >= 2 ? 'QUORUM: 2+ peers agree — this publish is confirmed.' : 'Only you so far. Ask another agent to hive_attest the same path; a single folder proves nothing.'}`)
|
|
536
|
+
}
|
|
537
|
+
case 'hive_verified': {
|
|
538
|
+
const f = String(args.file || ''); if (!f) return err('need a file path')
|
|
539
|
+
const r = requireSession().hive.verified(f)
|
|
540
|
+
if (!r.count) return text(`${f}: NOT attested by anyone. Unverified — measure it and call hive_attest.`)
|
|
541
|
+
const who = Object.entries(r.peers).map(([n, v]) => `${n}: ${v.bytes} bytes / ${v.lines} lines at ${v.at}`).join('\n ')
|
|
542
|
+
return text(`${f}\n ${who}\n\n${r.diverged ? '[!] DIVERGED — peers hold different bytes. Do not write or build on this path.' : r.quorum ? 'QUORUM: confirmed by 2+ peers at the same size.' : 'Attested by ONE peer only — not yet proof.'}`)
|
|
543
|
+
}
|
|
251
544
|
case 'hive_approve': { const r = requireSession().hive.decide(String(args.id), true); return r.ok ? text(`approved ${args.id}`) : err(r.error) }
|
|
252
545
|
case 'hive_deny': { const r = requireSession().hive.decide(String(args.id), false); return r.ok ? text(`denied ${args.id}`) : err(r.error) }
|
|
253
546
|
case 'hive_complete': { enforceDcoLock(); const r = requireSession().hive.complete(String(args.id), String(args.note || '')); return r.ok ? text(`completed ${args.id}`) : err(r.error) }
|
|
254
547
|
case 'hive_claim': {
|
|
255
|
-
enforceDcoLock();
|
|
256
548
|
const region = String(args.region || ''); if (!region) return err('need a region (file path)')
|
|
549
|
+
// Claiming does not spend a lease — it is not a write. But claiming a poisoned path is
|
|
550
|
+
// always a mistake, and refusing here is far cheaper than refusing after the agent has
|
|
551
|
+
// built the content it intended to write.
|
|
552
|
+
enforceDcoLock({ path: region });
|
|
257
553
|
const got = requireSession().hive.claim(region, String(args.intent || 'edit'))
|
|
258
554
|
if (got) return text(`claimed ${region} — it's yours. Edit it, then call hive_release.`)
|
|
259
555
|
const c = requireSession().hive.senseClaim(region)
|
|
@@ -261,15 +557,158 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
261
557
|
}
|
|
262
558
|
case 'hive_release': { const region = String(args.region || ''); if (!region) return err('need a region'); requireSession().hive.release(region); return text(`released ${region}`) }
|
|
263
559
|
case 'hive_claims': { const b = requireSession().hive.claimsBoard(); return text(b.length ? b.map((c) => `${c.region} — ${c.by}${c.intent ? ` (${c.intent})` : ''}`).join('\n') : '(nothing claimed right now — all open)') }
|
|
560
|
+
case 'hive_edit': {
|
|
561
|
+
const editPath = String(args.file || '')
|
|
562
|
+
// Path gate + lease consumed BEFORE the write, not after: a spent lease that still wrote is
|
|
563
|
+
// indistinguishable from no lease at all.
|
|
564
|
+
enforceDcoLock({ consumes: true, path: editPath })
|
|
565
|
+
const r = requireSession().hive.edit(editPath, String(args.content ?? ''), String(args.intent || ''))
|
|
566
|
+
if (r.error) return err(r.error)
|
|
567
|
+
return text(r.conflicted
|
|
568
|
+
? `wrote ${r.file}, but it now carries <<<<<<< conflict markers (a concurrent edit collided). Read the file and resolve before continuing.`
|
|
569
|
+
: `✓ wrote ${r.file} through the medium — your intent is signed into its provenance ledger and the change is syncing to every peer.`)
|
|
570
|
+
}
|
|
571
|
+
case 'hive_fork': {
|
|
572
|
+
const file = String(args.file || ''); if (!file) return err('need a file path')
|
|
573
|
+
const f = requireSession().hive.forkInfo(file)
|
|
574
|
+
if (!f) return text(`(no active fork on ${file} — nothing to resolve)`)
|
|
575
|
+
const vs = f.versions.map((v, i) => `--- VERSION ${i + 1}: ${v.name} — signed intent: "${v.intent || '(none stated)'}" ---\n${v.text ?? '(text unavailable)'}`).join('\n\n')
|
|
576
|
+
return text(`ACTIVE FORK on ${file} — ${f.versions.length} agents changed the same region concurrently. Reconcile ALL intents into one version and submit it with hive_resolve.\n\n--- COMMON BASE (what everyone edited from) ---\n${f.base ?? '(unavailable)'}\n\n${vs}`)
|
|
577
|
+
}
|
|
578
|
+
case 'hive_resolve': {
|
|
579
|
+
const file = String(args.file || ''); if (!file) return err('need a file path')
|
|
580
|
+
// A resolution IS a write, so it spends a lease and respects the path gate. Resolving a
|
|
581
|
+
// poisoned path is the exact move that produced two permanently disagreeing folders.
|
|
582
|
+
enforceDcoLock({ consumes: true, path: file })
|
|
583
|
+
const r = requireSession().hive.resolveFork(file, String(args.code ?? ''))
|
|
584
|
+
if (!r.ok) return err(`resolution NOT landed: ${r.reason}`)
|
|
585
|
+
const dropped = (r.coverage || []).filter((c) => !c.covered)
|
|
586
|
+
return text(`✓ resolution LANDED in ${file} — validated by ICR (parses, no dangling refs), signed by you, synced to every peer.${dropped.length ? ` ⚠ Intent coverage: ${dropped.map((c) => `${c.name}'s changes (${c.missing.slice(0, 5).join(', ')}) did not survive`).join('; ')} — if that was not deliberate, submit a better reconciliation.` : ' Full intent coverage: every side\'s changes survived.'}`)
|
|
587
|
+
}
|
|
264
588
|
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)') }
|
|
265
|
-
case 'hive_status': {
|
|
589
|
+
case 'hive_status': {
|
|
590
|
+
const s = requireSession()
|
|
591
|
+
// Connection state belongs here too, not only in hive_wait: this is the tool an
|
|
592
|
+
// agent reaches for when it suspects something is wrong, and the answer it needs
|
|
593
|
+
// is almost always "you are still connected, do nothing".
|
|
594
|
+
let conn = null
|
|
595
|
+
try { conn = s.hive.status() } catch { }
|
|
596
|
+
return text({
|
|
597
|
+
room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION,
|
|
598
|
+
unread: mailbox().peek(chatArray()).unreadCount,
|
|
599
|
+
connected: conn ? conn.connected : null,
|
|
600
|
+
filesSynced: conn ? `${conn.filesSynced}/${conn.files}` : null,
|
|
601
|
+
})
|
|
602
|
+
}
|
|
266
603
|
case 'hive_leave': { requireSession().hive.stop(); session = null; try { fs.rmSync(SESSION_FILE) } catch {}; return text('left the room') }
|
|
267
604
|
default: return err(`unknown tool: ${name}`)
|
|
268
605
|
}
|
|
269
606
|
} catch (e) { return err(`error: ${e.message}`) }
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// MAIL PIGGYBACK — the delivery channel that cannot be ignored.
|
|
610
|
+
//
|
|
611
|
+
// Everything else that tries to reach a busy agent is best-effort. `notifications/hive_mail` and
|
|
612
|
+
// `notifications/hive_state_changed` are CUSTOM MCP methods, and the spec requires a receiver to
|
|
613
|
+
// IGNORE notification methods it does not recognise — so on most hosts they are a doorbell wired to
|
|
614
|
+
// nothing. That is why a live four-agent build accumulated 345 mentions in the room log while agents
|
|
615
|
+
// repeatedly reported never seeing the one addressed to them, and why the room resorted to disk files
|
|
616
|
+
// and a shell watcher to do the job of a coordination tool.
|
|
617
|
+
//
|
|
618
|
+
// A TOOL RESULT, by contrast, lands in the model's context by definition. So mail rides out on every
|
|
619
|
+
// single tool call. An agent that is heads-down still calls tools constantly — that is what being
|
|
620
|
+
// heads-down IS — and now every one of those calls tells it who is waiting.
|
|
621
|
+
//
|
|
622
|
+
// PEEK, NOT TAKE, and the distinction is the whole design: this is a DOORBELL, not a delivery. It
|
|
623
|
+
// consumes nothing and advances no cursor, so hive_wait/hive_check remain the only things that hand
|
|
624
|
+
// over a message and mark it read. Delivering here instead would make the full text arrive on a
|
|
625
|
+
// random unrelated tool call and vanish from the inbox, which is worse than not delivering at all.
|
|
626
|
+
// A CONTEXT IS APPEND-ONLY, AND THAT CHANGES THE DESIGN.
|
|
627
|
+
//
|
|
628
|
+
// The obvious implementation — append the banner whenever mail is pending — is wrong in two ways, and
|
|
629
|
+
// both are the very drift SMP exists to prevent:
|
|
630
|
+
//
|
|
631
|
+
// 1. SPAM. Mail stays pending until the agent reads it, so 50 tool calls produce 50 identical
|
|
632
|
+
// banners, burning context to say one thing.
|
|
633
|
+
// 2. STALE TRUTH. Nothing can REMOVE text from a transcript. Once a banner is in context it is there
|
|
634
|
+
// for the rest of the session, so after the agent reads its mail the old "URGENT MAIL" notices
|
|
635
|
+
// remain, contradicting the present. An agent scanning back acts on a message handled ten
|
|
636
|
+
// minutes ago.
|
|
637
|
+
//
|
|
638
|
+
// Since removal is impossible, the only correct approach is to make LATER TEXT AUTHORITATIVE and say
|
|
639
|
+
// so explicitly:
|
|
640
|
+
//
|
|
641
|
+
// - emit ONLY when the mail state actually changes, so one new message = one notice
|
|
642
|
+
// - NUMBER each notice, and state that it supersedes every earlier one
|
|
643
|
+
// - emit exactly ONE "cleared" notice when the inbox empties, which retires the stale banners
|
|
644
|
+
// instead of leaving them as the newest thing the agent can see
|
|
645
|
+
//
|
|
646
|
+
// Signature includes the session name so a rejoin re-announces rather than inheriting a stale state.
|
|
647
|
+
let mailSig = ''
|
|
648
|
+
let mailNotice = 0
|
|
649
|
+
|
|
650
|
+
function mailBanner() {
|
|
651
|
+
try {
|
|
652
|
+
if (!session || !session.mailbox) return ''
|
|
653
|
+
const m = session.mailbox.peek(chatArray())
|
|
654
|
+
const frame = (body) => `\n\n--- MAIL NOTICE #${mailNotice} (supersedes every earlier MAIL NOTICE above) ---\n${body}\n--- end notice #${mailNotice} ---`
|
|
655
|
+
|
|
656
|
+
if (!m.mentioned) {
|
|
657
|
+
// Emit the all-clear once, and only if we had previously announced something. Without this the
|
|
658
|
+
// last thing in context stays "you have 2 unread", forever, after they were read.
|
|
659
|
+
if (!mailSig) return ''
|
|
660
|
+
mailSig = ''
|
|
661
|
+
mailNotice += 1
|
|
662
|
+
return frame('MAIL CLEARED: nothing is addressed to you right now. Any unread-mail notice earlier in this conversation has been handled and is now STALE — ignore it.')
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const last = m.mentions[m.mentions.length - 1] || {}
|
|
666
|
+
// Keyed on the NEWEST mention, not the count: a count alone would stay silent when one message is
|
|
667
|
+
// read and another arrives, and would re-fire on unrelated ambient chatter.
|
|
668
|
+
const sig = `${session.name}|${m.mentions.length}|${last.at || ''}|${last.by || ''}`
|
|
669
|
+
if (sig === mailSig) return '' // already announced — the notice is in context, do not repeat it
|
|
670
|
+
mailSig = sig
|
|
671
|
+
mailNotice += 1
|
|
672
|
+
|
|
673
|
+
const who = [...new Set(m.mentions.map((x) => x && x.by).filter(Boolean))].join(', ')
|
|
674
|
+
const urgent = m.priority === 'stop'
|
|
675
|
+
return frame(`${urgent ? '[!] URGENT MAIL' : 'UNREAD MAIL'}: ${m.mentions.length} message(s) addressed to you from ${who}. `
|
|
676
|
+
+ `${urgent ? 'Handle before continuing. ' : ''}Call hive_wait or hive_check to read them — this notice consumed nothing.`)
|
|
677
|
+
} catch { return '' }
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
681
|
+
const res = await dispatch(req)
|
|
682
|
+
// Never let the banner break a tool: append only to a well-formed text result, and skip the two
|
|
683
|
+
// tools that already deliver mail themselves (appending would double-report the same messages).
|
|
684
|
+
const skip = req.params && (req.params.name === 'hive_wait' || req.params.name === 'hive_check')
|
|
685
|
+
if (skip) return res
|
|
686
|
+
const banner = mailBanner()
|
|
687
|
+
if (!banner) return res
|
|
688
|
+
try {
|
|
689
|
+
if (res && Array.isArray(res.content) && res.content[0] && typeof res.content[0].text === 'string') {
|
|
690
|
+
return { ...res, content: [{ ...res.content[0], text: res.content[0].text + banner }, ...res.content.slice(1)] }
|
|
691
|
+
}
|
|
692
|
+
} catch { /* fall through — a delivered result matters more than a banner */ }
|
|
693
|
+
return res
|
|
270
694
|
})
|
|
271
695
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
696
|
+
// Only claim stdio when this file IS the program. Importing it (a test, a tool) must not
|
|
697
|
+
// hijack the JSON-RPC channel — that is what made the wait/mailbox logic untestable before.
|
|
698
|
+
const isEntryPoint = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
|
|
699
|
+
if (isEntryPoint) {
|
|
700
|
+
const transport = new StdioServerTransport()
|
|
701
|
+
await server.connect(transport)
|
|
702
|
+
// stderr is fine for logs; stdout is the JSON-RPC channel and must stay clean.
|
|
703
|
+
console.error('[hive-mcp] Hivecode MCP server ready (stdio). Default relay:', DEFAULT_RELAY)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Test seam: drive the real wait/delivery logic against a doc, with no relay and no stdio. */
|
|
707
|
+
export const _test = {
|
|
708
|
+
waitForWork,
|
|
709
|
+
mailbox,
|
|
710
|
+
setSession(s) { session = s },
|
|
711
|
+
getSession() { return session },
|
|
712
|
+
callTool: (name, args = {}) =>
|
|
713
|
+
server._requestHandlers.get('tools/call')({ method: 'tools/call', params: { name, arguments: args } }, {}),
|
|
714
|
+
}
|