hivecode-mcp 0.11.5 → 0.11.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HIVE_RULES.md +114 -28
- package/docs/MCP.md +33 -0
- package/hive-busy.js +88 -0
- package/hive-coord.js +14 -3
- package/hive-gate.js +135 -0
- package/hive-hook-install.js +103 -0
- package/hive-instructions.js +66 -0
- package/hive-mailbox.js +216 -3
- package/hive-mcp.js +468 -28
- package/hive-obligations.js +247 -0
- package/hive-receipts.js +233 -0
- package/hive-stop-hook.js +241 -0
- package/hive-throttle.js +118 -0
- package/package.json +29 -6
- package/sync.js +433 -12
package/hive-mcp.js
CHANGED
|
@@ -34,12 +34,30 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot
|
|
|
34
34
|
if (!process.env.HIVE_PROVENANCE) process.env.HIVE_PROVENANCE = 'on'
|
|
35
35
|
if (!process.env.HIVE_FORK_GATE) process.env.HIVE_FORK_GATE = 'on'
|
|
36
36
|
import { startSync, parseLink } from './sync.js'
|
|
37
|
-
import { createMailbox, formatMessage, mentionsName, mentionedNames, priorityOf, ambientDigest } from './hive-mailbox.js'
|
|
37
|
+
import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName } from './hive-mailbox.js'
|
|
38
|
+
import { INSTRUCTIONS } from './hive-instructions.js'
|
|
39
|
+
import { makeBusy, isBusy, describeRecipient } from './hive-busy.js'
|
|
40
|
+
import * as OB from './hive-obligations.js'
|
|
41
|
+
import * as RC from './hive-receipts.js'
|
|
42
|
+
import { makeThrottle, RULES_NUDGE } from './hive-throttle.js'
|
|
38
43
|
|
|
39
44
|
const DEFAULT_RELAY = process.env.HIVE_RELAY || 'wss://livecode-xoss.onrender.com'
|
|
40
45
|
let session = null // { hive, room, relay, dir, name, mailbox }
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
// ONE THROTTLE PER SERVER PROCESS, which is one agent. It deliberately does NOT live on the
|
|
48
|
+
// session: a rejoin does not empty the agent's context, so resetting the throttle there would
|
|
49
|
+
// re-send everything the agent can still see -- the exact waste this removes.
|
|
50
|
+
const throttle = makeThrottle()
|
|
51
|
+
|
|
52
|
+
const text = (s) => {
|
|
53
|
+
const body = typeof s === 'string' ? s : JSON.stringify(s, null, 2)
|
|
54
|
+
// THE METER. Every tool result leaves through here, so this count is exact rather than
|
|
55
|
+
// sampled -- it is the local equivalent of the PTY output bytes Agent Relay's broker measures,
|
|
56
|
+
// and it is the only evidence available that an agent has done enough since it last saw a
|
|
57
|
+
// block for that block to have been compacted away.
|
|
58
|
+
try { throttle.note(body.length) } catch { /* the meter must never break a tool result */ }
|
|
59
|
+
return { content: [{ type: 'text', text: body }] }
|
|
60
|
+
}
|
|
43
61
|
const err = (s) => ({ content: [{ type: 'text', text: s }], isError: true })
|
|
44
62
|
|
|
45
63
|
function resolveRoom(link, dir) {
|
|
@@ -69,6 +87,12 @@ function readSessionFile() {
|
|
|
69
87
|
function persistCursor() {
|
|
70
88
|
if (!session || !session.mailbox) return
|
|
71
89
|
persistSession({ dir: session.dir, name: session.name, owner: session.owner, cursor: session.mailbox.cursor })
|
|
90
|
+
// AND PUBLISH IT INTO THE ROOM. The file above is for resuming THIS session; the room copy is
|
|
91
|
+
// what makes "did they actually read it" answerable by anybody other than me. The cursor only
|
|
92
|
+
// advances inside box.take(), which is the call that hands messages to the agent -- so a cursor
|
|
93
|
+
// past a message is the one honest piece of evidence this system has that the message reached
|
|
94
|
+
// somebody's context. Before this, that evidence existed and was thrown away on disk.
|
|
95
|
+
try { RC.publishCursor(session.hive.doc.getMap('cursors'), session.name, session.mailbox.cursor) } catch { /* a room that predates receipts */ }
|
|
72
96
|
}
|
|
73
97
|
async function tryResumeSession() {
|
|
74
98
|
try {
|
|
@@ -79,6 +103,17 @@ async function tryResumeSession() {
|
|
|
79
103
|
}
|
|
80
104
|
|
|
81
105
|
async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor }) {
|
|
106
|
+
// REFUSED BEFORE THE OLD SESSION IS TORN DOWN. joinRoom's first act is to stop whatever session
|
|
107
|
+
// exists, so validating after that point would leave an agent with a bad name holding nothing at
|
|
108
|
+
// all -- it would lose a working room to a refusal it could have been given for free.
|
|
109
|
+
const nameCheck = validateMemberName(name)
|
|
110
|
+
if (!nameCheck.ok) {
|
|
111
|
+
return `${nameCheck.error}
|
|
112
|
+
|
|
113
|
+
Nothing was joined and your current session is untouched. Re-run hive_join with name: "${nameCheck.suggested}".
|
|
114
|
+
|
|
115
|
+
This is refused rather than fixed for you because renaming silently is worse: if another member is already called "${nameCheck.suggested}", you would be merged into them -- one awareness entry, one read cursor, one mailbox -- and neither of you would be told. Your own prompt would also still say your name is "${name}", so you would stop recognising messages about yourself.`
|
|
116
|
+
}
|
|
82
117
|
if (session) { try { session.hive.stop() } catch {} ; session = null }
|
|
83
118
|
const ROOT = path.resolve(dir)
|
|
84
119
|
fs.mkdirSync(ROOT, { recursive: true })
|
|
@@ -110,13 +145,52 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
|
|
|
110
145
|
}
|
|
111
146
|
session = { hive, room, relay, dir, name, owner, mailbox: null }
|
|
112
147
|
persistSession({ dir, name, owner }) // survive MCP host restarts (see tryResumeSession)
|
|
113
|
-
//
|
|
148
|
+
// Wait for the first sync so members/board/rules are ready.
|
|
149
|
+
//
|
|
150
|
+
// REPORT THE TRUTH ABOUT THIS. Until 18 Sep 2026 the timeout below simply resolved and the
|
|
151
|
+
// caller returned a cheerful "joined" banner whether or not the relay had accepted us. On a
|
|
152
|
+
// relay at capacity that is a false success report: the agent is told it is in a room, reads
|
|
153
|
+
// the rules, claims files and writes code, and NONE of it reaches anybody. It happened for
|
|
154
|
+
// real -- a relay answered 38,011 connections with Q430 while three agents believed they were
|
|
155
|
+
// collaborating. An agent cannot detect this on its own; the only honest place to catch it is
|
|
156
|
+
// here, at the moment we know whether the socket came up.
|
|
157
|
+
let synced = false
|
|
158
|
+
let connErr = ''
|
|
114
159
|
await new Promise((resolve) => {
|
|
115
160
|
let done = false
|
|
116
161
|
const finish = () => { if (!done) { done = true; resolve() } }
|
|
117
|
-
hive.provider.on('sync', (s) => s
|
|
162
|
+
hive.provider.on('sync', (s) => { if (s) { synced = true; finish() } })
|
|
163
|
+
// A refusal arrives as a failed upgrade: the relay writes an HTTP status line carrying the
|
|
164
|
+
// Q-code and destroys the socket, so this event is the only place the reason is visible.
|
|
165
|
+
hive.provider.on('connection-error', (ev) => {
|
|
166
|
+
const code = ev && (ev.code || ev.status || ev.type)
|
|
167
|
+
if (!connErr) connErr = `the relay refused the connection${code ? ` (${code})` : ''}`
|
|
168
|
+
})
|
|
118
169
|
setTimeout(finish, 8000) // don't hang if the relay is cold
|
|
119
170
|
})
|
|
171
|
+
if (!synced) {
|
|
172
|
+
const why = connErr || 'no sync within 8s -- the relay did not answer'
|
|
173
|
+
return [
|
|
174
|
+
`NOT JOINED -- ${why}.`,
|
|
175
|
+
`room: ${room}`,
|
|
176
|
+
`relay: ${relay}`,
|
|
177
|
+
`folder: ${ROOT}`,
|
|
178
|
+
'',
|
|
179
|
+
'DO NOT START WORK. You are not in this room. Nothing you write will reach anyone, no',
|
|
180
|
+
'claim you make binds anyone else, and the files in this folder are NOT shared. Any work',
|
|
181
|
+
'you do now is invisible to the team and can be silently overwritten later.',
|
|
182
|
+
'',
|
|
183
|
+
'Most likely causes, in the order worth checking:',
|
|
184
|
+
' - the relay is at capacity (Q430). Hivecode opens one socket PER FILE, so a large',
|
|
185
|
+
' folder can exhaust it by itself. Check <relay-http>/health: connections vs',
|
|
186
|
+
' limits.maxConnsTotal. Raise HIVE_MAX_CONNS_TOTAL, or narrow what syncs via .gitignore.',
|
|
187
|
+
' - the relay is not running, or the port/room in the link is wrong.',
|
|
188
|
+
' - the room is secured and this link carries no token (Q4xx on connect).',
|
|
189
|
+
'',
|
|
190
|
+
'Tell whoever is running the relay, then retry hive_join. Call hive_status to re-check:',
|
|
191
|
+
'it reports `connected` and `filesSynced`, and both are authoritative.',
|
|
192
|
+
].join('\n')
|
|
193
|
+
}
|
|
120
194
|
// The mailbox is created AFTER the first sync, so chat.length is the real room history and
|
|
121
195
|
// not 0 — otherwise a fresh joiner would be handed the entire backlog as unread mail.
|
|
122
196
|
// A resumed session carries its saved cursor forward instead, so anything posted while this
|
|
@@ -130,8 +204,18 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
|
|
|
130
204
|
|
|
131
205
|
// Event-Driven Wakeups: notify the MCP client the millisecond the DCO state changes
|
|
132
206
|
hive.doc.getMap('swarm_state').observe(() => {
|
|
207
|
+
// SAME DEFECT AS THE MAIL DOORBELL BELOW, AND FOUND THE SAME WAY -- a test crashed inside a
|
|
208
|
+
// Yjs observer with the stack pointing at a `try` that looked like it had the case covered.
|
|
209
|
+
// `notification()` is async: a failure is a REJECTED PROMISE, which sails past this catch,
|
|
210
|
+
// becomes an unhandled rejection and takes the whole agent down.
|
|
211
|
+
//
|
|
212
|
+
// Worth recording that the first fix only repaired the copy in the chat observer. This one
|
|
213
|
+
// survived untouched for another two tickets and then crashed a second test -- which is the
|
|
214
|
+
// duplicated-wire-contract lesson arriving in person: repair the CLASS, not the instance.
|
|
215
|
+
// Both call sites are now the same shape, deliberately.
|
|
133
216
|
try {
|
|
134
|
-
server.notification({ method: 'notifications/hive_state_changed', params: { state: hive.getState() } })
|
|
217
|
+
Promise.resolve(server.notification({ method: 'notifications/hive_state_changed', params: { state: hive.getState() } }))
|
|
218
|
+
.catch(() => { /* no transport, or a client that does not take notifications */ })
|
|
135
219
|
} catch (e) {
|
|
136
220
|
// client might not support notifications, swallow error
|
|
137
221
|
}
|
|
@@ -164,7 +248,17 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
|
|
|
164
248
|
// 0, and a later single mention must still ring. `>` would go permanently silent.
|
|
165
249
|
if (!m.mentioned || m.mentions.length === lastMailSignal) return
|
|
166
250
|
lastMailSignal = m.mentions.length
|
|
167
|
-
|
|
251
|
+
// THE CATCH BELOW DOES NOT COVER THIS CALL. `notification()` is async, so a failure inside
|
|
252
|
+
// it is a REJECTED PROMISE, not a thrown exception -- it sails past the surrounding
|
|
253
|
+
// try/catch, becomes an unhandled rejection, and takes the whole agent down. Observed
|
|
254
|
+
// 19 Sep 2026: a second agent posting chat to a server whose transport was not connected
|
|
255
|
+
// crashed the process from inside this Yjs observer, with the stack pointing at a `try`
|
|
256
|
+
// that looked like it had the case covered.
|
|
257
|
+
//
|
|
258
|
+
// A doorbell that can kill the agent is strictly worse than no doorbell -- and this one is
|
|
259
|
+
// already optional by design (custom MCP notifications are ignorable by spec), so the right
|
|
260
|
+
// behaviour on any failure is silence.
|
|
261
|
+
Promise.resolve(server.notification({
|
|
168
262
|
method: 'notifications/hive_mail',
|
|
169
263
|
params: {
|
|
170
264
|
mentioned: true,
|
|
@@ -174,7 +268,7 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
|
|
|
174
268
|
latest: formatMessage(m.mentions[m.mentions.length - 1] || {}),
|
|
175
269
|
hint: 'call hive_wait or hive_check to read it — this notification consumed nothing',
|
|
176
270
|
},
|
|
177
|
-
})
|
|
271
|
+
})).catch(() => { /* no transport, or a client that does not take notifications */ })
|
|
178
272
|
} catch (e) {
|
|
179
273
|
// client might not support notifications, swallow error
|
|
180
274
|
}
|
|
@@ -196,10 +290,21 @@ function requireSession() { if (!session) throw new Error('not in a room — cal
|
|
|
196
290
|
/** The read cursor for this participant. Lazily built if a session predates it. */
|
|
197
291
|
function mailbox() {
|
|
198
292
|
const s = requireSession()
|
|
293
|
+
// hive_join has just handed over the full HIVE_RULES.md, so the nudge would be pure noise on
|
|
294
|
+
// the next wait. Marking it sent here is what makes the join and the reminder one mechanism
|
|
295
|
+
// rather than two that disagree.
|
|
296
|
+
try { throttle.sent('rules', RULES_NUDGE) } catch { }
|
|
199
297
|
if (!s.mailbox) s.mailbox = createMailbox({ me: s.name, startAt: s.hive.doc.getArray('chat').length })
|
|
200
298
|
return s.mailbox
|
|
201
299
|
}
|
|
202
300
|
function chatArray() { return requireSession().hive.doc.getArray('chat').toArray() }
|
|
301
|
+
// Names are matched case-insensitively everywhere else (mentionsName is /i), so a busy lookup
|
|
302
|
+
// keyed on the exact string would silently miss whenever a sender typed @sam for Sam.
|
|
303
|
+
function findBusy(map, name) {
|
|
304
|
+
const want = String(name || '').toLowerCase()
|
|
305
|
+
for (const [k, v] of map.entries()) if (String(k).toLowerCase() === want) return v
|
|
306
|
+
return null
|
|
307
|
+
}
|
|
203
308
|
function boardMap() { const b = requireSession().hive.doc.getMap('board'); return [...b.entries()].map(([file, e]) => ({ file, ...e })) }
|
|
204
309
|
function taskList() { return [...requireSession().hive.doc.getMap('tasks').values()] }
|
|
205
310
|
const fmtTask = (t) => `${t.id} [${t.status}] ${t.by} -> ${t.to}: ${t.text}${t.decidedBy ? ` (by ${t.decidedBy})` : ''}`
|
|
@@ -207,6 +312,32 @@ const fmtTask = (t) => `${t.id} [${t.status}] ${t.by} -> ${t.to}: ${t.text}${t.d
|
|
|
207
312
|
// Block until there is approved work for me (or a new chat message), then return.
|
|
208
313
|
// This is how the agent reacts the INSTANT the owner approves — no polling loop,
|
|
209
314
|
// no interval to tune. Resolves immediately if approved work already exists.
|
|
315
|
+
/** WAKE ON STATE — the predicate that lets a roster run itself.
|
|
316
|
+
*
|
|
317
|
+
* Without this, hive_wait only returns on work assigned TO you or a message naming you, so every
|
|
318
|
+
* multi-agent roster silently depends on agents remembering to @ each other after each step. The
|
|
319
|
+
* rules ASK them to (sync.js: "block on hive_wait until your specific state flag turns
|
|
320
|
+
* APPROVED/READY") and nothing MAKES them, so a night roster stalls with no error: the reviewer
|
|
321
|
+
* is asleep, the tests went green an hour ago, and nobody said so.
|
|
322
|
+
*
|
|
323
|
+
* Shapes, all on one state key:
|
|
324
|
+
* { key: 'tests', is: 'GREEN' } exactly this value
|
|
325
|
+
* { key: 'tests', in: ['GREEN', 'SKIP'] } any of these
|
|
326
|
+
* { key: 'tests', not: 'RED' } anything except this (INCLUDING unset)
|
|
327
|
+
* { key: 'tests' } any value at all, i.e. the key exists
|
|
328
|
+
*
|
|
329
|
+
* `not` deliberately matches an unset key. A reviewer told "wake unless the build is RED" should
|
|
330
|
+
* wake on a fresh room where nobody has set `build` yet -- treating absent as RED would hang it
|
|
331
|
+
* forever waiting for a flag no one is going to write. */
|
|
332
|
+
export function stateMatches(until, get) {
|
|
333
|
+
if (!until || !until.key) return false
|
|
334
|
+
const v = get(until.key)
|
|
335
|
+
if (Object.prototype.hasOwnProperty.call(until, 'is')) return String(v) === String(until.is)
|
|
336
|
+
if (Array.isArray(until.in)) return until.in.some((x) => String(x) === String(v))
|
|
337
|
+
if (Object.prototype.hasOwnProperty.call(until, 'not')) return String(v) !== String(until.not)
|
|
338
|
+
return v !== undefined && v !== null
|
|
339
|
+
}
|
|
340
|
+
|
|
210
341
|
function waitForWork(timeoutMs, opts = {}) {
|
|
211
342
|
const s = requireSession()
|
|
212
343
|
const tmap = s.hive.doc.getMap('tasks')
|
|
@@ -251,6 +382,13 @@ function waitForWork(timeoutMs, opts = {}) {
|
|
|
251
382
|
}
|
|
252
383
|
// Queued work no longer hides the mail. This returned `newChat: []` outright, which made
|
|
253
384
|
// the busiest agent in the room the one guaranteed to see no messages at all.
|
|
385
|
+
// A state the caller is waiting for, checked BEFORE anything blocks: if it is already true the
|
|
386
|
+
// agent must not sit through a timeout waiting to be told something that happened an hour ago.
|
|
387
|
+
const state = s.hive.doc.getMap('swarm_state')
|
|
388
|
+
const until = opts.until && opts.until.key ? opts.until : null
|
|
389
|
+
const stateHit = () => stateMatches(until, (k) => state.get(k))
|
|
390
|
+
if (until && stateHit()) return Promise.resolve(deliver({ tasks: accepted(), stateReached: { ...until, value: state.get(until.key) } }))
|
|
391
|
+
|
|
254
392
|
const ready = accepted()
|
|
255
393
|
if (ready.length) return Promise.resolve(deliver({ tasks: ready }))
|
|
256
394
|
// Anything already unread is handed over immediately rather than waited past — that backlog
|
|
@@ -261,7 +399,7 @@ function waitForWork(timeoutMs, opts = {}) {
|
|
|
261
399
|
}
|
|
262
400
|
return new Promise((resolve) => {
|
|
263
401
|
let done = false
|
|
264
|
-
const finish = (v) => { if (!done) { done = true; tmap.unobserve(onTask); chat.unobserve(onChat); controls.unobserve(onCtl); resolve(v) } }
|
|
402
|
+
const finish = (v) => { if (!done) { done = true; tmap.unobserve(onTask); chat.unobserve(onChat); controls.unobserve(onCtl); if (until) { try { state.unobserve(onState) } catch { /* never observed */ } } resolve(v) } }
|
|
265
403
|
const onTask = () => { const a = accepted(); if (a.length) finish(deliver({ tasks: a })) }
|
|
266
404
|
const onChat = () => {
|
|
267
405
|
const seen = box.peek(chat.toArray())
|
|
@@ -270,7 +408,11 @@ function waitForWork(timeoutMs, opts = {}) {
|
|
|
270
408
|
finish(deliver({ tasks: accepted() }))
|
|
271
409
|
}
|
|
272
410
|
const onCtl = () => { if (paused()) finish({ paused: true }) } // paused mid-wait -> stop handing out work
|
|
273
|
-
|
|
411
|
+
// A paused agent is NOT woken by state: pausing means stop picking up work, and a state flag
|
|
412
|
+
// turning green is the room offering it work. It re-checks the predicate on its next wait
|
|
413
|
+
// after a human resumes it.
|
|
414
|
+
const onState = () => { if (until && !paused() && stateHit()) finish(deliver({ tasks: accepted(), stateReached: { ...until, value: state.get(until.key) } })) }
|
|
415
|
+
tmap.observe(onTask); chat.observe(onChat); controls.observe(onCtl); if (until) state.observe(onState)
|
|
274
416
|
// On timeout, flush anything that arrived rather than dropping it: a message must never
|
|
275
417
|
// expire unread just because the wait window closed on it.
|
|
276
418
|
setTimeout(() => finish(box.hasUnread(chat.toArray()) ? deliver({ tasks: accepted() }) : null), timeoutMs)
|
|
@@ -284,8 +426,8 @@ const TOOLS = [
|
|
|
284
426
|
inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name to assign to' }, text: { type: 'string', description: 'what to do' } }, required: ['to', 'text'] } },
|
|
285
427
|
{ name: 'hive_read_tasks', description: 'Read tasks. Shows tasks directed at YOU (act only on accepted ones) and all pending tasks awaiting approval.',
|
|
286
428
|
inputSchema: { type: 'object', properties: {} } },
|
|
287
|
-
{ name: 'hive_wait', description: 'BLOCK until there is approved work for you,
|
|
288
|
-
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." } } } },
|
|
429
|
+
{ name: 'hive_wait', description: 'BLOCK until there is approved work for you, unread mail arrives, or a project-state flag you named reaches the value you are waiting for — then return all of it. Use this as your main loop: call hive_wait; do the work; hive_complete; call hive_wait again. Returns instantly if any of those is ALREADY true, so nothing that happened while you were busy can be missed. Pass `until` to wait on the state machine instead of on somebody remembering to message you — that is how a roster runs itself overnight: the tester sets tests=GREEN, and the reviewer waiting on it wakes by itself.',
|
|
430
|
+
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." }, until: { type: 'object', description: 'wake when a GLOBAL PROJECT STATE key reaches a value. { key, is } for one exact value, { key, in: [...] } for any of several, { key, not } for anything else (an unset key counts as "not"), or { key } alone for "as soon as this is set at all". The result carries stateReached with the value that fired.', properties: { key: { type: 'string' }, is: { type: 'string' }, in: { type: 'array', items: { type: 'string' } }, not: { type: 'string' } }, required: ['key'] } } } },
|
|
289
431
|
{ 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.',
|
|
290
432
|
inputSchema: { type: 'object', properties: { peek: { type: 'boolean', description: 'true = look without marking messages read (default false: reading them here counts as read)' } } } },
|
|
291
433
|
{ name: 'hive_approve', description: 'Approve a pending task (you must be the owner of the target AI). The AI may then act on it.',
|
|
@@ -294,16 +436,28 @@ const TOOLS = [
|
|
|
294
436
|
inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
|
|
295
437
|
{ name: 'hive_complete', description: 'Mark a task you were doing as done (optionally with a note).',
|
|
296
438
|
inputSchema: { type: 'object', properties: { id: { type: 'string' }, note: { type: 'string' } }, required: ['id'] } },
|
|
297
|
-
{ 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.',
|
|
298
|
-
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'] } },
|
|
439
|
+
{ 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. Set needsReply:true when you are ASKING something — the room then tracks that you are owed an answer, and tells you if you addressed somebody who is not here.',
|
|
440
|
+
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"' }, needsReply: { type: 'boolean', description: 'true = this is a QUESTION and you are owed an answer. Records an obligation against everyone you named. It stays open until YOU call hive_answered -- the person you asked cannot close it, and answering is not closing. If you named someone who is not in the room you are told so immediately, instead of waiting forever.' }, replyWithinMinutes: { type: 'number', description: 'how long before you are reminded (default 10). The reminder arrives on a hive_wait you were going to make anyway, so it costs nothing.' } }, required: ['text'] } },
|
|
441
|
+
{ name: 'hive_delivered', description: 'Did anyone actually READ what you said? "sent" only means the message entered your local copy. This reports, per recipient: READ (their read position passed it — real evidence), UNREAD (they are here and behind), UNKNOWN (they publish no read position, e.g. a bridge or a human client — so their silence means NOTHING either way), UNREACHABLE (they left, or you are offline). Read it before concluding an agent is ignoring you, and before believing a handoff landed.',
|
|
442
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'how many of your recent addressed messages to check (default 10)' } } } },
|
|
443
|
+
{ name: 'hive_answered', description: 'Close a question YOU asked with needsReply — you are saying you got what you needed. Only the asker can call this: the agent you asked cannot mark its own answer sufficient, and a reply alone does not close anything. Pass id for one, or from:"Name" to close everything you asked that agent.',
|
|
444
|
+
inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'the obligation id from hive_say or hive_owed' }, from: { type: 'string', description: 'close every open question you asked this agent' } } } },
|
|
445
|
+
{ name: 'hive_owed', description: 'Who owes an answer to whom, and for how long. Read this when the room has gone quiet: an agent that stopped replying looks exactly like an agent that is thinking, and this is the difference. Shows questions aimed at YOU (answer these) and questions you asked (close these with hive_answered). Anything marked STALE has been reminded three times and is now the room\'s problem, not one agent\'s.',
|
|
446
|
+
inputSchema: { type: 'object', properties: { all: { type: 'boolean', description: 'true = the whole room, not just yours' } } } },
|
|
447
|
+
{ name: 'hive_busy', description: 'Tell the room you are mid-task, so anyone who @mentions you is told to expect a delay AT THE MOMENT THEY SEND, instead of waiting on a reply that is not coming yet. This posts no message and silences nothing — you still receive mail and must still call hive_check between edits. It expires by itself, so a crash cannot leave you permanently "busy". Clear it when you finish.',
|
|
448
|
+
inputSchema: { type: 'object', properties: { what: { type: 'string', description: 'what you are working on, in a few words — the sender reads this' }, minutes: { type: 'number', description: 'how long to hold it (default 10). It expires on its own either way.' }, clear: { type: 'boolean', description: 'true = I am free again' } } } },
|
|
299
449
|
{ 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.',
|
|
300
450
|
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)' } } } },
|
|
301
451
|
{ 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.',
|
|
302
452
|
inputSchema: { type: 'object', properties: {} } },
|
|
303
453
|
{ 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).',
|
|
304
454
|
inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'the file path (or region id) you want to edit' }, intent: { type: 'string', description: 'short note on what you intend to do' } }, required: ['region'] } },
|
|
305
|
-
{ name: 'hive_release', description: '
|
|
306
|
-
inputSchema: { type: 'object', properties: {
|
|
455
|
+
{ name: 'hive_release', description: 'Hand a file back so other agents can take it. If the room has set a gate, this RUNS it in your folder first and a failing check REFUSES the handoff — you keep the file, nobody else can take it, and retrying will not help. Fix what the gate reported, or pass force with a reason if you are abandoning the work (recorded against your name and announced).',
|
|
456
|
+
inputSchema: { type: 'object', properties: {
|
|
457
|
+
region: { type: 'string', description: 'the file path (or region id) to release' },
|
|
458
|
+
force: { type: 'boolean', description: 'release even though the gate failed. Recorded against your name in the room and posted to chat — use it for abandoned work, never to get past a failing check.' },
|
|
459
|
+
why: { type: 'string', description: 'why the work is being abandoned. Give this whenever force is true; a forced release with no reason is recorded as exactly that.' },
|
|
460
|
+
}, required: ['region'] } },
|
|
307
461
|
{ 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.',
|
|
308
462
|
inputSchema: { type: 'object', properties: {} } },
|
|
309
463
|
{ 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.',
|
|
@@ -439,7 +593,15 @@ export const VERSION = (() => {
|
|
|
439
593
|
} catch { /* fall through */ }
|
|
440
594
|
return '0.0.0-unknown'
|
|
441
595
|
})()
|
|
442
|
-
|
|
596
|
+
// `instructions` is placed in the agent's SYSTEM PROMPT by the client at connect time, which
|
|
597
|
+
// makes it the only channel that reaches an agent before its first tool call. Everything else we
|
|
598
|
+
// have -- tool descriptions, the HIVE_RULES returned by hive_join -- is read too late or scrolls
|
|
599
|
+
// away, and the rule that matters most (hive_wait is your idle state; ending your turn makes you
|
|
600
|
+
// unreachable) is exactly the one an agent needs up front. See hive-instructions.js.
|
|
601
|
+
const server = new Server(
|
|
602
|
+
{ name: 'hivecode', version: VERSION },
|
|
603
|
+
{ capabilities: { tools: {} }, instructions: INSTRUCTIONS },
|
|
604
|
+
)
|
|
443
605
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
|
|
444
606
|
async function dispatch(req) {
|
|
445
607
|
const { name, arguments: args = {} } = req.params
|
|
@@ -452,6 +614,14 @@ async function dispatch(req) {
|
|
|
452
614
|
switch (name) {
|
|
453
615
|
case 'hive_join': return text(await joinRoom(args))
|
|
454
616
|
case 'hive_say': {
|
|
617
|
+
// THE CONNECTION IS CHECKED BEFORE ANYTHING ELSE. Until today this tool reported
|
|
618
|
+
// "sent to @Builder: ..." with the socket down, which is a false success in the most
|
|
619
|
+
// literal sense: the bytes are in a local Yjs doc, the relay has never seen them, and the
|
|
620
|
+
// word the agent reads is the same word it reads on success. status().connected was
|
|
621
|
+
// right there and free the whole time.
|
|
622
|
+
const sendConn = (() => { try { return requireSession().hive.status() } catch { return null } })()
|
|
623
|
+
const sendWarn = RC.sendWarning({ connected: !sendConn || !!sendConn.connected })
|
|
624
|
+
const stamp = (t) => (sendWarn ? `${sendWarn}\n\n${t}` : t)
|
|
455
625
|
const raw = String(args.text || '')
|
|
456
626
|
// The marker is in the text itself so it survives into HIVE_CHAT.md and is visible to
|
|
457
627
|
// a human reading the log, not just to the recipient's tool output.
|
|
@@ -463,12 +633,85 @@ async function dispatch(req) {
|
|
|
463
633
|
// Immediate, per-call feedback, at the moment the mistake is made.
|
|
464
634
|
const named = mentionedNames(body)
|
|
465
635
|
if (!named.length) {
|
|
466
|
-
return text(`sent: ${body}
|
|
636
|
+
return text(stamp(`sent: ${body}`) + `\n\nNOBODY WAS ADDRESSED. This went to every agent's "room ` +
|
|
467
637
|
`activity", where they are told to skip it — so assume no one acts on it. Name the ` +
|
|
468
638
|
`agents you actually need with @Name (hive_members lists who is here). Use an ` +
|
|
469
639
|
`unaddressed message only for a genuine announcement to the whole room.`)
|
|
470
640
|
}
|
|
471
|
-
|
|
641
|
+
// WHO IS ACTUALLY GOING TO READ THIS. The sender is the only participant who can act on
|
|
642
|
+
// "they left" or "they are mid-task", and it is worth strictly more here than in a
|
|
643
|
+
// reply: it arrives before the sender starts waiting, and it costs the room no message.
|
|
644
|
+
// This is the whole reason busy is state rather than an auto-reply (see hive-busy.js).
|
|
645
|
+
const s = requireSession()
|
|
646
|
+
const present = new Set(s.hive.members().map((m) => String(m.name).toLowerCase()))
|
|
647
|
+
const busyMap = s.hive.doc.getMap('busy')
|
|
648
|
+
const notes = named
|
|
649
|
+
.filter((n) => n.toLowerCase() !== 'all' && n.toLowerCase() !== s.name.toLowerCase())
|
|
650
|
+
.map((n) => describeRecipient(n, findBusy(busyMap, n), present.has(n.toLowerCase())))
|
|
651
|
+
.filter(Boolean)
|
|
652
|
+
const to = mentionsAll(body) ? 'the whole room (@all)' : named.map((n) => '@' + n).join(', ')
|
|
653
|
+
// NEEDS REPLY. A question is recorded, not just spoken. One obligation per agent named,
|
|
654
|
+
// and `absent` is stamped NOW -- because "was anyone even there when I asked" is a fact
|
|
655
|
+
// about the past, and recomputing it later would let a question asked into an empty room
|
|
656
|
+
// start looking fine the moment somebody walks in. This is the 19 Sep 2026 failure
|
|
657
|
+
// exactly: one message to @Reviewer, who had never been in the room, then six hours of
|
|
658
|
+
// silence that no tool, log or process reported.
|
|
659
|
+
let obStr = ''
|
|
660
|
+
if (args.needsReply) {
|
|
661
|
+
const obMap = s.hive.doc.getMap('obligations')
|
|
662
|
+
// Floor in MILLISECONDS, not minutes. `Math.max(1, mins)` silently turned a caller's
|
|
663
|
+
// 0.05 into 1 -- a clamp that changes what was asked for and says nothing, which is the
|
|
664
|
+
// same shape as every other defect on this project. One second is enough to stop a
|
|
665
|
+
// zero-or-negative busy loop, and everything above it is honoured as written.
|
|
666
|
+
const dueMs = Math.max(1000, (Number(args.replyWithinMinutes) || 10) * 60000)
|
|
667
|
+
const targets = mentionsAll(body)
|
|
668
|
+
? s.hive.members().map((m) => String(m.name)).filter((n) => n.toLowerCase() !== s.name.toLowerCase())
|
|
669
|
+
: named.filter((n) => n.toLowerCase() !== 'all' && n.toLowerCase() !== s.name.toLowerCase())
|
|
670
|
+
// PRESENCE AND PROVENANCE ARE DIFFERENT FACTS AND THEY DECIDE DIFFERENT THINGS.
|
|
671
|
+
// `absent` decides the WARNING: nobody by that name is connected right now, so say so
|
|
672
|
+
// at once. `neverSeen` decides the LIFETIME: nobody by that name has ever joined or
|
|
673
|
+
// spoken here, so this is noise rather than a teammate who stepped out.
|
|
674
|
+
// Conflating them would expire a real debt: on 20 Sep an obligation against
|
|
675
|
+
// @DeepMind-Agent was stamped absent because it had dropped between turns, and it came
|
|
676
|
+
// back ninety minutes later and answered. A ten-minute timer would have deleted it, and
|
|
677
|
+
// the ledger would have been at its cleanest exactly while an agent was silent.
|
|
678
|
+
const everHere = new Set([...present])
|
|
679
|
+
for (const m of s.hive.doc.getArray('chat').toArray()) {
|
|
680
|
+
if (m && m.by) everHere.add(String(m.by).toLowerCase())
|
|
681
|
+
}
|
|
682
|
+
const opened = targets.map((n) => OB.open(obMap, {
|
|
683
|
+
from: s.name, to: n, text: body, dueMs,
|
|
684
|
+
absent: !present.has(n.toLowerCase()),
|
|
685
|
+
neverSeen: !everHere.has(n.toLowerCase()),
|
|
686
|
+
}))
|
|
687
|
+
if (opened.length) {
|
|
688
|
+
obStr = `\n\nYOU ARE OWED AN ANSWER — ${opened.map((o) => `${o.id} (${o.to})`).join(', ')}\n` +
|
|
689
|
+
`This stays open until YOU call hive_answered. The agent you asked cannot close it, and ` +
|
|
690
|
+
`a reply does not close it either — you decide whether you were actually answered. If it ` +
|
|
691
|
+
`is still open in ${Math.round(dueMs / 60000)} min you will be reminded on your next hive_wait.`
|
|
692
|
+
const ghosts = opened.filter((o) => o.absent)
|
|
693
|
+
if (ghosts.length) {
|
|
694
|
+
obStr += `\n\n⚠ ${ghosts.map((o) => '@' + o.to).join(', ')} — NOT IN THIS ROOM. Nobody by that ` +
|
|
695
|
+
`name is here, so nobody is going to answer. Call hive_members and re-address it now; ` +
|
|
696
|
+
`this is the one failure mode that otherwise costs you a whole shift.`
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return text(stamp(`sent to ${to}: ${body}`) + (notes.length ? '\n\n' + notes.join('\n') : '') + obStr)
|
|
701
|
+
}
|
|
702
|
+
case 'hive_busy': {
|
|
703
|
+
const s = requireSession()
|
|
704
|
+
const busyMap = s.hive.doc.getMap('busy')
|
|
705
|
+
if (args.clear) { busyMap.delete(s.name); return text('busy cleared — the room will expect normal replies from you again') }
|
|
706
|
+
const e = makeBusy(args.what, { ttl: args.minutes ? args.minutes * 60000 : undefined })
|
|
707
|
+
busyMap.set(s.name, e)
|
|
708
|
+
// Say what it does NOT do, because an agent that believes this silences its mail will
|
|
709
|
+
// stop calling hive_check and go deaf on purpose.
|
|
710
|
+
return text(`marked busy: ${e.what}\n\nThis posts no message and silences nothing — you still ` +
|
|
711
|
+
`receive mail and must still call hive_check between edits. It only tells anyone who ` +
|
|
712
|
+
`@mentions you, at the moment they send, that you are mid-task. It expires by itself in ` +
|
|
713
|
+
`${Math.round(e.ttl / 60000)} minutes, so a crash cannot leave the room believing you are ` +
|
|
714
|
+
`permanently busy. Call hive_busy with clear:true when you finish.`)
|
|
472
715
|
}
|
|
473
716
|
case 'hive_read_chat': {
|
|
474
717
|
const me = requireSession().name
|
|
@@ -496,7 +739,22 @@ async function dispatch(req) {
|
|
|
496
739
|
const got = args.peek ? box.peek(all) : box.take(all)
|
|
497
740
|
if (!args.peek) persistCursor()
|
|
498
741
|
const tasks = [...s.hive.doc.getMap('tasks').values()].filter((t) => t.to === s.name && t.status === 'accepted')
|
|
499
|
-
|
|
742
|
+
// WHO IS STILL WAITING ON YOU. Read mail is not answered mail: the cursor advances the
|
|
743
|
+
// moment a message is handed over, so an agent can absorb a direct question, get busy,
|
|
744
|
+
// and never notice it left someone hanging -- which is the failure that made a previous
|
|
745
|
+
// room's PM give up on this channel and tail the chat file with a shell script instead.
|
|
746
|
+
// Derived from the log, so it needs no state and cannot drift.
|
|
747
|
+
const owed = unansweredFor(all, s.name)
|
|
748
|
+
const owedLine = owed.length
|
|
749
|
+
? `\n\n⏳ STILL WAITING ON A REPLY FROM YOU: ${owed.map((o) => '@' + o.from).join(', ')}. ` +
|
|
750
|
+
`Answer with hive_say (name them), even if the answer is "not yet, I am on X" — ` +
|
|
751
|
+
`silence is the one thing they cannot act on.`
|
|
752
|
+
: ''
|
|
753
|
+
if (!got.unreadCount && !tasks.length) {
|
|
754
|
+
return text(owed.length
|
|
755
|
+
? `✓ no new mail and no approved work.${owedLine}`
|
|
756
|
+
: '✓ nothing waiting — nobody is calling you and you have no approved work. Carry on.')
|
|
757
|
+
}
|
|
500
758
|
const parts = []
|
|
501
759
|
if (got.priority === 'stop') {
|
|
502
760
|
// HANDLING URGENT MAIL COUNTS AS LIVENESS.
|
|
@@ -529,7 +787,7 @@ async function dispatch(req) {
|
|
|
529
787
|
const dig = ambientDigest(ambient)
|
|
530
788
|
if (dig) parts.push(dig)
|
|
531
789
|
if (tasks.length) parts.push(`approved work queued for you:\n${tasks.map(fmtTask).join('\n')}`)
|
|
532
|
-
return text(parts.join('\n\n'))
|
|
790
|
+
return text(parts.join('\n\n') + owedLine)
|
|
533
791
|
}
|
|
534
792
|
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)') }
|
|
535
793
|
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') }
|
|
@@ -541,9 +799,23 @@ async function dispatch(req) {
|
|
|
541
799
|
}
|
|
542
800
|
case 'hive_wait': {
|
|
543
801
|
const ms = Math.min(Math.max(Number(args.timeoutSeconds) || 60, 1), 300) * 1000
|
|
544
|
-
const r = await waitForWork(ms, { wakeOn: args.wakeOn })
|
|
802
|
+
const r = await waitForWork(ms, { wakeOn: args.wakeOn, until: args.until })
|
|
545
803
|
const globalState = requireSession().hive.getState()
|
|
546
|
-
|
|
804
|
+
// THE STATE BLOCK WAS RETURNED ON EVERY SINGLE WAIT, unchanged, forever -- an agent
|
|
805
|
+
// sitting in the night-shift loop was handed the same JSON hundreds of times and paid for
|
|
806
|
+
// it each time. It is now sent when the agent has not seen it, when it CHANGED (which
|
|
807
|
+
// always wins: a suppressed state change is a wrong answer, not a saving), or when enough
|
|
808
|
+
// time AND enough output have passed that it is probably no longer in context.
|
|
809
|
+
const stateBody = Object.keys(globalState).length ? `\n[GLOBAL PROJECT STATE]\n${JSON.stringify(globalState, null, 2)}\n` : ''
|
|
810
|
+
const stateStr = stateBody && throttle.send('state', stateBody) ? stateBody : ''
|
|
811
|
+
// The room rules went the other way: 6 KB handed over ONCE in the hive_join result, where
|
|
812
|
+
// compaction reaches them first. They are the ROOM-specific half -- lanes, gate command,
|
|
813
|
+
// ownership -- so the general rules in the system prompt do not cover them. A pointer is
|
|
814
|
+
// re-sent, never a copy: the file is on this agent's own disk, and a second summary here
|
|
815
|
+
// would be one more description to drift out of date.
|
|
816
|
+
const rulesStr = throttle.should('rules', RULES_NUDGE).why === 'likely-compacted'
|
|
817
|
+
? (throttle.sent('rules', RULES_NUDGE), `\n${RULES_NUDGE}\n`)
|
|
818
|
+
: ''
|
|
547
819
|
// Connection state, reported ONLY when it is bad. An agent that cannot tell a
|
|
548
820
|
// quiet room from a dead socket reaches for hive_join -- which calls stop() and
|
|
549
821
|
// tears the session down, turning a blip that would have healed itself into a
|
|
@@ -558,6 +830,25 @@ async function dispatch(req) {
|
|
|
558
830
|
: ''
|
|
559
831
|
|
|
560
832
|
const me = requireSession().name
|
|
833
|
+
// THE KNOCK RIDES THE WAIT. hive_wait is the agent's idle state and a blocked wait costs
|
|
834
|
+
// zero tokens, so a reminder delivered here arrives on a call that was going to happen
|
|
835
|
+
// anyway. Agent Relay re-injects its boomerang on a broker timer, which spends a turn on
|
|
836
|
+
// every knock; this spends nothing. The reminder is deliberately NOT sent to a paused
|
|
837
|
+
// agent -- paused means stop, and nagging a stopped agent is just noise.
|
|
838
|
+
const obMap = (() => { try { return requireSession().hive.doc.getMap('obligations') } catch { return null } })()
|
|
839
|
+
let owedStr = ''
|
|
840
|
+
if (obMap) {
|
|
841
|
+
const due = OB.overdue(obMap, me)
|
|
842
|
+
if (due.length) {
|
|
843
|
+
// Only a DELIVERED knock moves the deadline. If merely NOTICING an overdue record
|
|
844
|
+
// rescheduled it, an agent that never calls hive_wait would have its deadline pushed
|
|
845
|
+
// out forever by whoever happened to read the board -- exactly backwards.
|
|
846
|
+
for (const o of due) OB.knock(obMap, o.id)
|
|
847
|
+
owedStr = `\n[YOU OWE ${due.length} ANSWER(S)] These were asked of you and are still open. ` +
|
|
848
|
+
`Answer in chat with @Name — you cannot close them yourself, the asker does that.\n` +
|
|
849
|
+
due.map((o) => ' ' + OB.describe(o)).join('\n') + '\n'
|
|
850
|
+
}
|
|
851
|
+
}
|
|
561
852
|
// Direct address is separated from ambient traffic on every path. It used to be that
|
|
562
853
|
// only a PAUSED agent got this distinction, so a working agent could not tell "stop,
|
|
563
854
|
// wrong file" from two other agents talking to each other.
|
|
@@ -577,13 +868,67 @@ async function dispatch(req) {
|
|
|
577
868
|
}
|
|
578
869
|
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}`)
|
|
579
870
|
}
|
|
580
|
-
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}`)
|
|
871
|
+
if (!r) return text(`(nothing yet — no approved work or new messages in the wait window. Call hive_wait again to keep waiting.)${owedStr}${connStr}${stateStr}${rulesStr}`)
|
|
581
872
|
const parts = []
|
|
582
873
|
if (r.resumed) parts.push('▶ You were RESUMED by mission control — continue.')
|
|
583
874
|
const mb = mentionBlock(r); if (mb) parts.push(mb)
|
|
584
875
|
if (r.tasks.length) parts.push(`APPROVED WORK — do these now, then hive_complete each:\n${r.tasks.map(fmtTask).join('\n')}`)
|
|
585
876
|
const ab = ambientBlock(r); if (ab) parts.push(ab)
|
|
586
|
-
return text(`${connStr}${stateStr}\n` + (parts.join('\n\n') || '(resumed — no queued work yet. Call hive_wait again.)'))
|
|
877
|
+
return text(`${owedStr}${connStr}${stateStr}${rulesStr}\n` + (parts.join('\n\n') || '(resumed — no queued work yet. Call hive_wait again.)'))
|
|
878
|
+
}
|
|
879
|
+
case 'hive_delivered': {
|
|
880
|
+
const s2 = requireSession()
|
|
881
|
+
const conn = (() => { try { return s2.hive.status() } catch { return null } })()
|
|
882
|
+
const connected = !conn || !!conn.connected
|
|
883
|
+
const here = new Set(s2.hive.members().map((m) => String(m.name).toLowerCase()))
|
|
884
|
+
const rs = RC.receipts({
|
|
885
|
+
chat: s2.hive.doc.getArray('chat').toArray(),
|
|
886
|
+
cursors: s2.hive.doc.getMap('cursors'),
|
|
887
|
+
me: s2.name,
|
|
888
|
+
present: (n) => here.has(String(n).toLowerCase()),
|
|
889
|
+
connected,
|
|
890
|
+
limit: Math.min(Math.max(Number(args.limit) || 10, 1), 50),
|
|
891
|
+
})
|
|
892
|
+
if (!rs.length) return text('You have not addressed anyone recently, so there is nothing to confirm. (Unaddressed and @all messages are not tracked — nobody promised to read those.)')
|
|
893
|
+
const states = rs.flatMap((r) => r.to)
|
|
894
|
+
const read = RC.countDelivered(states)
|
|
895
|
+
const unknown = states.filter((x) => x.state === RC.UNKNOWN).length
|
|
896
|
+
// The headline deliberately reports READ over TOTAL and names UNKNOWN separately rather
|
|
897
|
+
// than folding it in. Summing "not proven undelivered" into the good number is how a
|
|
898
|
+
// receipt system starts reassuring instead of measuring.
|
|
899
|
+
const head = `${read}/${states.length} confirmed READ` +
|
|
900
|
+
(unknown ? ` — ${unknown} UNKNOWN (no read position published; that is an absence of evidence, not evidence of delivery)` : '')
|
|
901
|
+
const worst = states.filter((x) => x.state !== RC.READ)
|
|
902
|
+
return text(`${head}\n\n` + rs.map((r) => ' ' + RC.describe(r)).join('\n') +
|
|
903
|
+
(worst.length ? '\n\n' + [...new Set(worst.map((x) => `${x.name}: ${x.why}`))].join('\n') : ''))
|
|
904
|
+
}
|
|
905
|
+
case 'hive_answered': {
|
|
906
|
+
const s2 = requireSession()
|
|
907
|
+
const obMap = s2.hive.doc.getMap('obligations')
|
|
908
|
+
if (args.from) {
|
|
909
|
+
const closed = OB.dischargeFrom(obMap, { by: s2.name, to: String(args.from) })
|
|
910
|
+
if (!closed.length) return text(`Nothing open that you asked @${args.from}. hive_owed shows what is.`)
|
|
911
|
+
return text(`Closed ${closed.length} question(s) you asked @${args.from}:\n` + closed.map((o) => ' ' + o.text).join('\n'))
|
|
912
|
+
}
|
|
913
|
+
if (!args.id) return err('hive_answered needs either id (one question) or from (everything you asked one agent). hive_owed lists both.')
|
|
914
|
+
const r2 = OB.discharge(obMap, { id: String(args.id), by: s2.name })
|
|
915
|
+
if (!r2.ok) return err(r2.reason)
|
|
916
|
+
return text(r2.already ? `${args.id} was already closed.` : `Closed ${args.id}: "${r2.rec.text}"`)
|
|
917
|
+
}
|
|
918
|
+
case 'hive_owed': {
|
|
919
|
+
const s2 = requireSession()
|
|
920
|
+
const obMap = s2.hive.doc.getMap('obligations')
|
|
921
|
+
if (args.all) {
|
|
922
|
+
const b = OB.board(obMap)
|
|
923
|
+
if (!b.open) return text('Nothing outstanding in this room.')
|
|
924
|
+
return text(`${b.open} open, ${b.stale} stale:\n` + b.lines.map((l) => ' ' + l).join('\n'))
|
|
925
|
+
}
|
|
926
|
+
const mine = OB.owedBy(obMap, s2.name), theirs = OB.owedTo(obMap, s2.name)
|
|
927
|
+
if (!mine.length && !theirs.length) return text('You owe nothing and are owed nothing. (Pass all:true for the whole room.)')
|
|
928
|
+
const parts = []
|
|
929
|
+
if (mine.length) parts.push(`YOU OWE ${mine.length} ANSWER(S) — answer in chat with @Name; the asker closes it:\n` + mine.map((o) => ' ' + OB.describe(o)).join('\n'))
|
|
930
|
+
if (theirs.length) parts.push(`YOU ARE OWED ${theirs.length} — close with hive_answered once you actually have what you needed:\n` + theirs.map((o) => ' ' + OB.describe(o)).join('\n'))
|
|
931
|
+
return text(parts.join('\n\n'))
|
|
587
932
|
}
|
|
588
933
|
case 'hive_set_state': { requireSession().hive.setState(String(args.key), String(args.val)); return text(`Set global state [${args.key}] to [${args.val}]`) }
|
|
589
934
|
case 'hive_read_state': { return text(requireSession().hive.getState()) }
|
|
@@ -601,9 +946,29 @@ async function dispatch(req) {
|
|
|
601
946
|
case 'hive_verified': {
|
|
602
947
|
const f = String(args.file || ''); if (!f) return err('need a file path')
|
|
603
948
|
const r = requireSession().hive.verified(f)
|
|
604
|
-
|
|
949
|
+
// Attestation and gate verdicts are independent records, so an unattested path must still
|
|
950
|
+
// report its gate. Returning early here would hide a FAILED check behind "nobody measured
|
|
951
|
+
// the bytes", which is the opposite of what this tool is for.
|
|
952
|
+
if (!r.count) {
|
|
953
|
+
const g0 = requireSession().hive.gateVerdicts(f)
|
|
954
|
+
const extra = Object.entries(g0).map(([n, v]) => v.forced
|
|
955
|
+
? `\n [!] ${n} FORCE-released it without passing the gate${v.why ? ` — "${v.why}"` : ''}`
|
|
956
|
+
: `\n ${v.pass ? '[ok]' : '[!]'} ${n}'s gate ${v.pass ? 'passed' : `FAILED (exit ${v.exit})`}`).join('')
|
|
957
|
+
return text(`${f}: NOT attested by anyone. Unverified — measure it and call hive_attest.${extra}`)
|
|
958
|
+
}
|
|
605
959
|
const who = Object.entries(r.peers).map(([n, v]) => `${n}: ${v.bytes} bytes / ${v.lines} lines at ${v.at}`).join('\n ')
|
|
606
|
-
|
|
960
|
+
// Same bytes in two folders says the file PROPAGATED. It says nothing about whether it
|
|
961
|
+
// WORKS, which is the actual question an agent is asking here — so the gate verdicts
|
|
962
|
+
// belong in this answer. A file two peers agree on and whose owner's tests failed is the
|
|
963
|
+
// exact thing somebody is about to build on.
|
|
964
|
+
const g = requireSession().hive.gateVerdicts(f)
|
|
965
|
+
const gLines = Object.entries(g).map(([n, v]) => v.forced
|
|
966
|
+
? ` [!] ${n} FORCE-released it without passing the gate${v.why ? ` — "${v.why}"` : ' and gave no reason'}`
|
|
967
|
+
: ` ${v.pass ? '[ok]' : '[!]'} ${n}'s gate ${v.pass ? 'passed' : `FAILED (exit ${v.exit})`}: ${v.cmd}`)
|
|
968
|
+
const gate = gLines.length
|
|
969
|
+
? `\n\nCHECKS RUN BY THE AGENTS THAT HELD IT:\n${gLines.join('\n')}${Object.values(g).some((v) => !v.pass) ? '\n Somebody handed this back knowing it did not pass. Read it before you build on it.' : ''}`
|
|
970
|
+
: '\n\nNo gate verdict recorded for this path — nobody has run a check on it here.'
|
|
971
|
+
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.'}${gate}`)
|
|
607
972
|
}
|
|
608
973
|
case 'hive_approve': { const r = requireSession().hive.decide(String(args.id), true); return r.ok ? text(`approved ${args.id}`) : err(r.error) }
|
|
609
974
|
case 'hive_deny': { const r = requireSession().hive.decide(String(args.id), false); return r.ok ? text(`denied ${args.id}`) : err(r.error) }
|
|
@@ -614,18 +979,52 @@ async function dispatch(req) {
|
|
|
614
979
|
// always a mistake, and refusing here is far cheaper than refusing after the agent has
|
|
615
980
|
// built the content it intended to write.
|
|
616
981
|
enforceDcoLock({ path: region });
|
|
617
|
-
|
|
982
|
+
// HIV-27: A CLAIM YOU CANNOT PUBLISH IS NOT A CLAIM. The claim is a path string in the
|
|
983
|
+
// shared map; whether that path's BYTES sync is decided separately, per client. Nothing
|
|
984
|
+
// compared them, so an agent could hold an exclusive claim on a file nobody else can
|
|
985
|
+
// see, do the work, pass a gate running in its own folder where the file is real, and
|
|
986
|
+
// hand back something that never existed for anyone else -- every signal green.
|
|
987
|
+
// Refused HERE rather than at release: by release time the work is already built and
|
|
988
|
+
// all we could do is tell the agent it was wasted.
|
|
989
|
+
const unshared = requireSession().hive.whyNotShared(region)
|
|
990
|
+
if (unshared) {
|
|
991
|
+
return err(`refusing to claim ${region} — ${unshared}
|
|
992
|
+
|
|
993
|
+
Nothing was claimed. Put the path inside the room's sync scope, or pick one that is already in it, BEFORE you start work: a claim whose bytes never leave this folder is indistinguishable from one that works.`)
|
|
994
|
+
}
|
|
995
|
+
// SETTLED, not optimistic. The synchronous claim answers from this replica alone and
|
|
996
|
+
// tells three simultaneous callers "it's yours" (measured 5/5). An agent then edits
|
|
997
|
+
// behind a lock it does not hold. This waits for the relay to serialise it.
|
|
998
|
+
const got = await requireSession().hive.claimSettled(region, String(args.intent || 'edit'))
|
|
618
999
|
if (got) return text(`claimed ${region} — it's yours. Edit it, then call hive_release.`)
|
|
619
1000
|
const c = requireSession().hive.senseClaim(region)
|
|
620
1001
|
return text(`could NOT claim ${region}${c ? ` — held by ${c.by}${c.intent ? ` (${c.intent})` : ''}` : ''}. Work on something else; check hive_claims for open files.`)
|
|
621
1002
|
}
|
|
622
|
-
case 'hive_release': {
|
|
1003
|
+
case 'hive_release': {
|
|
1004
|
+
const region = String(args.region || '')
|
|
1005
|
+
if (!region) return err('need a region')
|
|
1006
|
+
// A gated release can REFUSE, and reporting "released" either way is the worst thing this
|
|
1007
|
+
// could do: the agent moves on believing it handed the file back while it still holds it.
|
|
1008
|
+
const r = requireSession().hive.release(region, { force: !!args.force, why: String(args.why || '') })
|
|
1009
|
+
if (r && r.ok === false) return text(r.message)
|
|
1010
|
+
if (r && r.forced) return text(`released ${region} WITHOUT passing the gate — recorded against your name in the room`)
|
|
1011
|
+
return text(`released ${region}${r && r.gated ? ' (gate passed)' : ''}`)
|
|
1012
|
+
}
|
|
623
1013
|
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)') }
|
|
624
1014
|
case 'hive_edit': {
|
|
625
1015
|
const editPath = String(args.file || '')
|
|
626
1016
|
// Path gate + lease consumed BEFORE the write, not after: a spent lease that still wrote is
|
|
627
1017
|
// indistinguishable from no lease at all.
|
|
628
1018
|
enforceDcoLock({ consumes: true, path: editPath })
|
|
1019
|
+
// HIV-27, same reasoning: this tool's entire promise is that the change reaches every
|
|
1020
|
+
// peer. On a path this client never opens a connection for, it would not -- and it
|
|
1021
|
+
// would still report success.
|
|
1022
|
+
const unshared = requireSession().hive.whyNotShared(editPath)
|
|
1023
|
+
if (unshared) {
|
|
1024
|
+
return err(`refusing to write ${editPath} through the medium — ${unshared}
|
|
1025
|
+
|
|
1026
|
+
Nothing was written. This tool exists to guarantee the change syncs to every peer; on this path it cannot.`)
|
|
1027
|
+
}
|
|
629
1028
|
const r = requireSession().hive.edit(editPath, String(args.content ?? ''), String(args.intent || ''))
|
|
630
1029
|
if (r.error) return err(r.error)
|
|
631
1030
|
return text(r.conflicted
|
|
@@ -741,7 +1140,44 @@ function mailBanner() {
|
|
|
741
1140
|
} catch { return '' }
|
|
742
1141
|
}
|
|
743
1142
|
|
|
1143
|
+
// A MISSING REQUIRED ARGUMENT MUST BE REFUSED, NOT WRITTEN AS `undefined`.
|
|
1144
|
+
//
|
|
1145
|
+
// Nothing was validating `inputSchema.required`. The SDK does not enforce it, and every handler
|
|
1146
|
+
// read its arguments straight off the object, so a near-miss parameter name sailed through as
|
|
1147
|
+
// `undefined` and the tool reported success. Two real cases, both on 18 Sep 2026:
|
|
1148
|
+
// hive_say({ message }) -> posted an EMPTY message and answered "sent: "
|
|
1149
|
+
// hive_set_state({ value }) -> answered "Set global state [gate:cmd] to [undefined]", which
|
|
1150
|
+
// overwrote the room's gate command with the string "undefined"
|
|
1151
|
+
// and would have broken the handoff gate for every agent.
|
|
1152
|
+
// The failure mode is what makes it serious: the agent is told the action happened. It moves on,
|
|
1153
|
+
// and the room is left holding a corrupted value nobody wrote on purpose. Near-miss names are
|
|
1154
|
+
// the common case, so the refusal names the unknown argument that was supplied too.
|
|
1155
|
+
function missingRequired(req) {
|
|
1156
|
+
const name = req && req.params && req.params.name
|
|
1157
|
+
const tool = TOOLS.find((t) => t.name === name)
|
|
1158
|
+
const schema = tool && tool.inputSchema
|
|
1159
|
+
const required = schema && Array.isArray(schema.required) ? schema.required : []
|
|
1160
|
+
if (!required.length) return ''
|
|
1161
|
+
const args = (req.params && req.params.arguments) || {}
|
|
1162
|
+
// undefined/null only: '' and 0 and false are legitimate values a caller may mean.
|
|
1163
|
+
const missing = required.filter((k) => args[k] === undefined || args[k] === null)
|
|
1164
|
+
if (!missing.length) return ''
|
|
1165
|
+
const known = Object.keys(schema.properties || {})
|
|
1166
|
+
const unknown = Object.keys(args).filter((k) => !known.includes(k))
|
|
1167
|
+
const lines = [
|
|
1168
|
+
`REFUSED -- ${name} needs ${missing.map((m) => `\`${m}\``).join(', ')} and it was not supplied.`,
|
|
1169
|
+
`Nothing was written. This call did NOT happen.`,
|
|
1170
|
+
]
|
|
1171
|
+
if (unknown.length) {
|
|
1172
|
+
lines.push(`You passed ${unknown.map((u) => `\`${u}\``).join(', ')}, which ${name} does not take -- most likely you meant ${missing.map((m) => `\`${m}\``).join(', ')}.`)
|
|
1173
|
+
}
|
|
1174
|
+
lines.push(`${name} accepts: ${known.map((k) => (required.includes(k) ? `${k} (required)` : k)).join(', ')}.`)
|
|
1175
|
+
return lines.join('\n')
|
|
1176
|
+
}
|
|
1177
|
+
|
|
744
1178
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
1179
|
+
const bad = missingRequired(req)
|
|
1180
|
+
if (bad) return { content: [{ type: 'text', text: bad }], isError: true }
|
|
745
1181
|
const res = await dispatch(req)
|
|
746
1182
|
// Never let the banner break a tool: append only to a well-formed text result, and skip the two
|
|
747
1183
|
// tools that already deliver mail themselves (appending would double-report the same messages).
|
|
@@ -771,6 +1207,10 @@ if (isEntryPoint) {
|
|
|
771
1207
|
export const _test = {
|
|
772
1208
|
waitForWork,
|
|
773
1209
|
mailbox,
|
|
1210
|
+
// Exposed so the throttle's saving is asserted against the counter itself rather than inferred
|
|
1211
|
+
// from what happened not to appear in a tool result. Counting absences is how you prove a thing
|
|
1212
|
+
// is quiet, not how you prove it is working.
|
|
1213
|
+
throttleStats: () => throttle.stats(),
|
|
774
1214
|
setSession(s) { session = s },
|
|
775
1215
|
getSession() { return session },
|
|
776
1216
|
callTool: (name, args = {}) =>
|