hivecode-mcp 0.11.5 → 0.12.0
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 +127 -41
- package/README.md +250 -13
- package/core.js +14 -146
- package/docs/MCP.md +34 -1
- package/docs/SELF_HOSTING.md +253 -0
- package/hive-activity.js +310 -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 +760 -45
- package/hive-obligations.js +247 -0
- package/hive-pr-staleness.js +189 -0
- package/hive-privacy.js +263 -0
- package/hive-receipts.js +233 -0
- package/hive-relay-health.js +205 -0
- package/hive-stop-hook.js +241 -0
- package/hive-throttle.js +118 -0
- package/hive-version.js +59 -0
- package/lang-js.js +11 -2
- package/package.json +42 -6
- package/reconcile.cjs +477 -0
- package/sync.js +940 -30
package/hive-mcp.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Register it with an MCP client (e.g. Claude Code / Claude Desktop):
|
|
6
6
|
// command: node args: ["<abs-path>/hive-mcp.js"]
|
|
7
|
-
// env (optional): HIVE_RELAY = wss://your-relay (defaults to
|
|
7
|
+
// env (optional): HIVE_RELAY = wss://your-relay (defaults to PUBLIC_DEFAULT_RELAY in hive-privacy.js)
|
|
8
8
|
//
|
|
9
9
|
// Tools exposed:
|
|
10
10
|
// hive_join join/host a room for a folder (auto kind:ai) -> returns the rules
|
|
@@ -34,12 +34,33 @@ 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 {
|
|
37
|
+
import { checkRelay, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
|
|
38
|
+
import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName } from './hive-mailbox.js'
|
|
39
|
+
import { INSTRUCTIONS } from './hive-instructions.js'
|
|
40
|
+
import { makeBusy, isBusy, describeRecipient } from './hive-busy.js'
|
|
41
|
+
import * as ACT from './hive-activity.js' // HIV-P11; key MUST be 'activity'
|
|
42
|
+
import * as RH from './hive-relay-health.js' // HIV-P13; the ONLY producer of the not-syncing diagnosis
|
|
43
|
+
import * as OB from './hive-obligations.js'
|
|
44
|
+
import * as RC from './hive-receipts.js'
|
|
45
|
+
import { makeThrottle, RULES_NUDGE } from './hive-throttle.js'
|
|
38
46
|
|
|
39
|
-
const DEFAULT_RELAY = process.env.HIVE_RELAY ||
|
|
47
|
+
const DEFAULT_RELAY = process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY // written once, in hive-privacy.js
|
|
40
48
|
let session = null // { hive, room, relay, dir, name, mailbox }
|
|
41
49
|
|
|
42
|
-
|
|
50
|
+
// ONE THROTTLE PER SERVER PROCESS, which is one agent. It deliberately does NOT live on the
|
|
51
|
+
// session: a rejoin does not empty the agent's context, so resetting the throttle there would
|
|
52
|
+
// re-send everything the agent can still see -- the exact waste this removes.
|
|
53
|
+
const throttle = makeThrottle()
|
|
54
|
+
|
|
55
|
+
const text = (s) => {
|
|
56
|
+
const body = typeof s === 'string' ? s : JSON.stringify(s, null, 2)
|
|
57
|
+
// THE METER. Every tool result leaves through here, so this count is exact rather than
|
|
58
|
+
// sampled -- it is the local equivalent of the PTY output bytes Agent Relay's broker measures,
|
|
59
|
+
// and it is the only evidence available that an agent has done enough since it last saw a
|
|
60
|
+
// block for that block to have been compacted away.
|
|
61
|
+
try { throttle.note(body.length) } catch { /* the meter must never break a tool result */ }
|
|
62
|
+
return { content: [{ type: 'text', text: body }] }
|
|
63
|
+
}
|
|
43
64
|
const err = (s) => ({ content: [{ type: 'text', text: s }], isError: true })
|
|
44
65
|
|
|
45
66
|
function resolveRoom(link, dir) {
|
|
@@ -69,6 +90,12 @@ function readSessionFile() {
|
|
|
69
90
|
function persistCursor() {
|
|
70
91
|
if (!session || !session.mailbox) return
|
|
71
92
|
persistSession({ dir: session.dir, name: session.name, owner: session.owner, cursor: session.mailbox.cursor })
|
|
93
|
+
// AND PUBLISH IT INTO THE ROOM. The file above is for resuming THIS session; the room copy is
|
|
94
|
+
// what makes "did they actually read it" answerable by anybody other than me. The cursor only
|
|
95
|
+
// advances inside box.take(), which is the call that hands messages to the agent -- so a cursor
|
|
96
|
+
// past a message is the one honest piece of evidence this system has that the message reached
|
|
97
|
+
// somebody's context. Before this, that evidence existed and was thrown away on disk.
|
|
98
|
+
try { RC.publishCursor(session.hive.doc.getMap('cursors'), session.name, session.mailbox.cursor) } catch { /* a room that predates receipts */ }
|
|
72
99
|
}
|
|
73
100
|
async function tryResumeSession() {
|
|
74
101
|
try {
|
|
@@ -78,11 +105,40 @@ async function tryResumeSession() {
|
|
|
78
105
|
} catch { /* resume is best-effort; the agent can always hive_join explicitly */ }
|
|
79
106
|
}
|
|
80
107
|
|
|
81
|
-
async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor }) {
|
|
108
|
+
async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false }) {
|
|
109
|
+
// REFUSED BEFORE THE OLD SESSION IS TORN DOWN. joinRoom's first act is to stop whatever session
|
|
110
|
+
// exists, so validating after that point would leave an agent with a bad name holding nothing at
|
|
111
|
+
// all -- it would lose a working room to a refusal it could have been given for free.
|
|
112
|
+
const nameCheck = validateMemberName(name)
|
|
113
|
+
if (!nameCheck.ok) {
|
|
114
|
+
return `${nameCheck.error}
|
|
115
|
+
|
|
116
|
+
Nothing was joined and your current session is untouched. Re-run hive_join with name: "${nameCheck.suggested}".
|
|
117
|
+
|
|
118
|
+
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.`
|
|
119
|
+
}
|
|
82
120
|
if (session) { try { session.hive.stop() } catch {} ; session = null }
|
|
83
121
|
const ROOT = path.resolve(dir)
|
|
84
122
|
fs.mkdirSync(ROOT, { recursive: true })
|
|
85
123
|
const { relay, room, token: linkToken, mode } = resolveRoom(link, dir)
|
|
124
|
+
// THE PRIVACY GATE, and note where it sits: after the relay is resolved, BEFORE the
|
|
125
|
+
// .hive.json write below and before startSync opens a socket. A refused join must leave
|
|
126
|
+
// nothing behind -- no config pointing at the wrong relay for the next process to find,
|
|
127
|
+
// and above all no published file. Checking after connecting would report a leak instead
|
|
128
|
+
// of preventing one, which is the difference between a guard and a log line.
|
|
129
|
+
const priv = checkRelay({ relay, env: process.env, dir: ROOT, fs })
|
|
130
|
+
if (!priv.ok) {
|
|
131
|
+
return `${priv.headline}
|
|
132
|
+
|
|
133
|
+
${priv.detail}
|
|
134
|
+
|
|
135
|
+
room: ${room}
|
|
136
|
+
mode: ${mode}
|
|
137
|
+
folder: ${ROOT}
|
|
138
|
+
|
|
139
|
+
NOTHING WAS JOINED. No socket was opened, no file was published, and ${path.join(ROOT, '.hive.json')}
|
|
140
|
+
was not written. Fix the relay and call hive_join again.`
|
|
141
|
+
}
|
|
86
142
|
// token precedence: explicit arg > token baked into the join link > $HIVE_TOKEN.
|
|
87
143
|
const useToken = token || linkToken || process.env.HIVE_TOKEN || ''
|
|
88
144
|
// persist the token too, so a re-join from .hive.json keeps the grant (the
|
|
@@ -98,7 +154,7 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
|
|
|
98
154
|
// folder is precisely the situation the lock exists to prevent.
|
|
99
155
|
let hive
|
|
100
156
|
try {
|
|
101
|
-
hive = startSync({ relay, room, dir, name, kind: 'ai', owner, token: useToken, log: () => {} })
|
|
157
|
+
hive = startSync({ relay, room, dir, name, kind: kind || 'ai', owner, token: useToken, log: () => {}, passive: !!passive })
|
|
102
158
|
} catch (e) {
|
|
103
159
|
if (e && e.code === 'EHIVELOCKED') {
|
|
104
160
|
return text(
|
|
@@ -108,15 +164,76 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
|
|
|
108
164
|
}
|
|
109
165
|
throw e
|
|
110
166
|
}
|
|
111
|
-
session = { hive, room, relay, dir, name, owner, mailbox: null }
|
|
167
|
+
session = { hive, room, relay, dir, name, owner, kind: kind || 'ai', passive: !!passive, mailbox: null }
|
|
112
168
|
persistSession({ dir, name, owner }) // survive MCP host restarts (see tryResumeSession)
|
|
113
|
-
//
|
|
169
|
+
// Wait for the first sync so members/board/rules are ready.
|
|
170
|
+
//
|
|
171
|
+
// REPORT THE TRUTH ABOUT THIS. Until 18 Sep 2026 the timeout below simply resolved and the
|
|
172
|
+
// caller returned a cheerful "joined" banner whether or not the relay had accepted us. On a
|
|
173
|
+
// relay at capacity that is a false success report: the agent is told it is in a room, reads
|
|
174
|
+
// the rules, claims files and writes code, and NONE of it reaches anybody. It happened for
|
|
175
|
+
// real -- a relay answered 38,011 connections with Q430 while three agents believed they were
|
|
176
|
+
// collaborating. An agent cannot detect this on its own; the only honest place to catch it is
|
|
177
|
+
// here, at the moment we know whether the socket came up.
|
|
178
|
+
let synced = false
|
|
179
|
+
let connErr = ''
|
|
180
|
+
let refusedCode = null
|
|
114
181
|
await new Promise((resolve) => {
|
|
115
182
|
let done = false
|
|
116
183
|
const finish = () => { if (!done) { done = true; resolve() } }
|
|
117
|
-
hive.provider.on('sync', (s) => s
|
|
184
|
+
hive.provider.on('sync', (s) => { if (s) { synced = true; finish() } })
|
|
185
|
+
// A refusal arrives as a failed upgrade: the relay writes an HTTP status line carrying the
|
|
186
|
+
// Q-code and destroys the socket, so this event is the only place the reason is visible.
|
|
187
|
+
hive.provider.on('connection-error', (ev) => {
|
|
188
|
+
const code = RH.statusFromError(ev) || (ev && (ev.code || ev.status || ev.type))
|
|
189
|
+
if (code && typeof code === 'number') refusedCode = code
|
|
190
|
+
if (!connErr) connErr = `the relay refused the connection${code ? ` (${code})` : ''}`
|
|
191
|
+
})
|
|
118
192
|
setTimeout(finish, 8000) // don't hang if the relay is cold
|
|
119
193
|
})
|
|
194
|
+
if (!synced) {
|
|
195
|
+
// HIV-P12. EVIDENCE BEFORE INFERENCE.
|
|
196
|
+
// If the relay actually refused us with an HTTP status, explainStatus() maps it to the
|
|
197
|
+
// authoritative cause (Q430 -> capacity, Q431 -> HIVE_SEATS, 401/403 -> token).
|
|
198
|
+
// Only when nothing refused us with a code do we query /health to infer whether it was capacity.
|
|
199
|
+
if (refusedCode) {
|
|
200
|
+
const explanation = RH.explainStatus(refusedCode, relay)
|
|
201
|
+
return [
|
|
202
|
+
`NOT JOINED -- the relay refused the connection (${refusedCode}): ${explanation}`,
|
|
203
|
+
`room: ${room}`,
|
|
204
|
+
`relay: ${relay}`,
|
|
205
|
+
`folder: ${ROOT}`,
|
|
206
|
+
'',
|
|
207
|
+
'DO NOT START WORK. You are not in this room. Nothing you write will reach anyone, no',
|
|
208
|
+
'claim you make binds anyone else, and the files in this folder are NOT shared. Any work',
|
|
209
|
+
'you do now is invisible to the team and can be silently overwritten later.',
|
|
210
|
+
'',
|
|
211
|
+
'Tell whoever is running the relay, then retry hive_join. Call hive_status to re-check:',
|
|
212
|
+
'it reports `connected` and `filesSynced`, and both are authoritative.',
|
|
213
|
+
].join('\n')
|
|
214
|
+
}
|
|
215
|
+
const why = connErr || 'no sync within 8s -- the relay did not answer'
|
|
216
|
+
const diag = await RH.explainNotSyncing(relay, { what: 'join' })
|
|
217
|
+
return [
|
|
218
|
+
`NOT JOINED -- ${why}.`,
|
|
219
|
+
`room: ${room}`,
|
|
220
|
+
`relay: ${relay}`,
|
|
221
|
+
`folder: ${ROOT}`,
|
|
222
|
+
'',
|
|
223
|
+
'DO NOT START WORK. You are not in this room. Nothing you write will reach anyone, no',
|
|
224
|
+
'claim you make binds anyone else, and the files in this folder are NOT shared. Any work',
|
|
225
|
+
'you do now is invisible to the team and can be silently overwritten later.',
|
|
226
|
+
'',
|
|
227
|
+
diag.text,
|
|
228
|
+
'',
|
|
229
|
+
'Two causes the relay cannot tell you about, worth checking if the above says capacity is fine:',
|
|
230
|
+
' - the relay is not running, or the port/room in the link is wrong.',
|
|
231
|
+
' - the room is secured and this link carries no token (Q4xx on connect).',
|
|
232
|
+
'',
|
|
233
|
+
'Tell whoever is running the relay, then retry hive_join. Call hive_status to re-check:',
|
|
234
|
+
'it reports `connected` and `filesSynced`, and both are authoritative.',
|
|
235
|
+
].join('\n')
|
|
236
|
+
}
|
|
120
237
|
// The mailbox is created AFTER the first sync, so chat.length is the real room history and
|
|
121
238
|
// not 0 — otherwise a fresh joiner would be handed the entire backlog as unread mail.
|
|
122
239
|
// A resumed session carries its saved cursor forward instead, so anything posted while this
|
|
@@ -130,8 +247,18 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
|
|
|
130
247
|
|
|
131
248
|
// Event-Driven Wakeups: notify the MCP client the millisecond the DCO state changes
|
|
132
249
|
hive.doc.getMap('swarm_state').observe(() => {
|
|
250
|
+
// SAME DEFECT AS THE MAIL DOORBELL BELOW, AND FOUND THE SAME WAY -- a test crashed inside a
|
|
251
|
+
// Yjs observer with the stack pointing at a `try` that looked like it had the case covered.
|
|
252
|
+
// `notification()` is async: a failure is a REJECTED PROMISE, which sails past this catch,
|
|
253
|
+
// becomes an unhandled rejection and takes the whole agent down.
|
|
254
|
+
//
|
|
255
|
+
// Worth recording that the first fix only repaired the copy in the chat observer. This one
|
|
256
|
+
// survived untouched for another two tickets and then crashed a second test -- which is the
|
|
257
|
+
// duplicated-wire-contract lesson arriving in person: repair the CLASS, not the instance.
|
|
258
|
+
// Both call sites are now the same shape, deliberately.
|
|
133
259
|
try {
|
|
134
|
-
server.notification({ method: 'notifications/hive_state_changed', params: { state: hive.getState() } })
|
|
260
|
+
Promise.resolve(server.notification({ method: 'notifications/hive_state_changed', params: { state: hive.getState() } }))
|
|
261
|
+
.catch(() => { /* no transport, or a client that does not take notifications */ })
|
|
135
262
|
} catch (e) {
|
|
136
263
|
// client might not support notifications, swallow error
|
|
137
264
|
}
|
|
@@ -164,7 +291,17 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
|
|
|
164
291
|
// 0, and a later single mention must still ring. `>` would go permanently silent.
|
|
165
292
|
if (!m.mentioned || m.mentions.length === lastMailSignal) return
|
|
166
293
|
lastMailSignal = m.mentions.length
|
|
167
|
-
|
|
294
|
+
// THE CATCH BELOW DOES NOT COVER THIS CALL. `notification()` is async, so a failure inside
|
|
295
|
+
// it is a REJECTED PROMISE, not a thrown exception -- it sails past the surrounding
|
|
296
|
+
// try/catch, becomes an unhandled rejection, and takes the whole agent down. Observed
|
|
297
|
+
// 19 Sep 2026: a second agent posting chat to a server whose transport was not connected
|
|
298
|
+
// crashed the process from inside this Yjs observer, with the stack pointing at a `try`
|
|
299
|
+
// that looked like it had the case covered.
|
|
300
|
+
//
|
|
301
|
+
// A doorbell that can kill the agent is strictly worse than no doorbell -- and this one is
|
|
302
|
+
// already optional by design (custom MCP notifications are ignorable by spec), so the right
|
|
303
|
+
// behaviour on any failure is silence.
|
|
304
|
+
Promise.resolve(server.notification({
|
|
168
305
|
method: 'notifications/hive_mail',
|
|
169
306
|
params: {
|
|
170
307
|
mentioned: true,
|
|
@@ -174,7 +311,7 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
|
|
|
174
311
|
latest: formatMessage(m.mentions[m.mentions.length - 1] || {}),
|
|
175
312
|
hint: 'call hive_wait or hive_check to read it — this notification consumed nothing',
|
|
176
313
|
},
|
|
177
|
-
})
|
|
314
|
+
})).catch(() => { /* no transport, or a client that does not take notifications */ })
|
|
178
315
|
} catch (e) {
|
|
179
316
|
// client might not support notifications, swallow error
|
|
180
317
|
}
|
|
@@ -189,17 +326,37 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
|
|
|
189
326
|
// The invite link carries the token when there is one — without it a secured room rejects
|
|
190
327
|
// whoever you hand it to, and the Control Room says the link is missing its token.
|
|
191
328
|
const inviteLink = `${relay}|${room}${useToken ? `|${useToken}` : ''}`
|
|
192
|
-
|
|
329
|
+
// THE VERDICT, NOT JUST THE URL. `relay: wss://...` on its own is unreadable to anybody
|
|
330
|
+
// who has not run a relay -- the public one and a company's own look identical. The
|
|
331
|
+
// bracketed verdict is what makes "did my source just leave the building?" answerable
|
|
332
|
+
// without asking, and the warning line below is what makes it actionable.
|
|
333
|
+
const privNote = priv.warn ? `\n\n!! ${priv.warn}` : ''
|
|
334
|
+
return `${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION}\nothers here: ${others.join(', ') || 'none yet'}\ninvite link: ${inviteLink}${privNote}\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}`
|
|
193
335
|
}
|
|
194
336
|
|
|
195
337
|
function requireSession() { if (!session) throw new Error('not in a room — call hive_join first'); return session }
|
|
196
338
|
/** The read cursor for this participant. Lazily built if a session predates it. */
|
|
197
339
|
function mailbox() {
|
|
198
340
|
const s = requireSession()
|
|
341
|
+
// hive_join has just handed over the full HIVE_RULES.md, so the nudge would be pure noise on
|
|
342
|
+
// the next wait. Marking it sent here is what makes the join and the reminder one mechanism
|
|
343
|
+
// rather than two that disagree.
|
|
344
|
+
try { throttle.sent('rules', RULES_NUDGE) } catch { }
|
|
199
345
|
if (!s.mailbox) s.mailbox = createMailbox({ me: s.name, startAt: s.hive.doc.getArray('chat').length })
|
|
200
346
|
return s.mailbox
|
|
201
347
|
}
|
|
202
348
|
function chatArray() { return requireSession().hive.doc.getArray('chat').toArray() }
|
|
349
|
+
|
|
350
|
+
// HIV-P11. Returns null rather than throwing when there is no session: a liveness stamp is an
|
|
351
|
+
// observation about a tool call, not part of it, and it must never be the reason a tool fails.
|
|
352
|
+
function activityMap() { try { return requireSession().hive.doc.getMap('activity') } catch { return null } }
|
|
353
|
+
// Names are matched case-insensitively everywhere else (mentionsName is /i), so a busy lookup
|
|
354
|
+
// keyed on the exact string would silently miss whenever a sender typed @sam for Sam.
|
|
355
|
+
function findBusy(map, name) {
|
|
356
|
+
const want = String(name || '').toLowerCase()
|
|
357
|
+
for (const [k, v] of map.entries()) if (String(k).toLowerCase() === want) return v
|
|
358
|
+
return null
|
|
359
|
+
}
|
|
203
360
|
function boardMap() { const b = requireSession().hive.doc.getMap('board'); return [...b.entries()].map(([file, e]) => ({ file, ...e })) }
|
|
204
361
|
function taskList() { return [...requireSession().hive.doc.getMap('tasks').values()] }
|
|
205
362
|
const fmtTask = (t) => `${t.id} [${t.status}] ${t.by} -> ${t.to}: ${t.text}${t.decidedBy ? ` (by ${t.decidedBy})` : ''}`
|
|
@@ -207,6 +364,32 @@ const fmtTask = (t) => `${t.id} [${t.status}] ${t.by} -> ${t.to}: ${t.text}${t.d
|
|
|
207
364
|
// Block until there is approved work for me (or a new chat message), then return.
|
|
208
365
|
// This is how the agent reacts the INSTANT the owner approves — no polling loop,
|
|
209
366
|
// no interval to tune. Resolves immediately if approved work already exists.
|
|
367
|
+
/** WAKE ON STATE — the predicate that lets a roster run itself.
|
|
368
|
+
*
|
|
369
|
+
* Without this, hive_wait only returns on work assigned TO you or a message naming you, so every
|
|
370
|
+
* multi-agent roster silently depends on agents remembering to @ each other after each step. The
|
|
371
|
+
* rules ASK them to (sync.js: "block on hive_wait until your specific state flag turns
|
|
372
|
+
* APPROVED/READY") and nothing MAKES them, so a night roster stalls with no error: the reviewer
|
|
373
|
+
* is asleep, the tests went green an hour ago, and nobody said so.
|
|
374
|
+
*
|
|
375
|
+
* Shapes, all on one state key:
|
|
376
|
+
* { key: 'tests', is: 'GREEN' } exactly this value
|
|
377
|
+
* { key: 'tests', in: ['GREEN', 'SKIP'] } any of these
|
|
378
|
+
* { key: 'tests', not: 'RED' } anything except this (INCLUDING unset)
|
|
379
|
+
* { key: 'tests' } any value at all, i.e. the key exists
|
|
380
|
+
*
|
|
381
|
+
* `not` deliberately matches an unset key. A reviewer told "wake unless the build is RED" should
|
|
382
|
+
* wake on a fresh room where nobody has set `build` yet -- treating absent as RED would hang it
|
|
383
|
+
* forever waiting for a flag no one is going to write. */
|
|
384
|
+
export function stateMatches(until, get) {
|
|
385
|
+
if (!until || !until.key) return false
|
|
386
|
+
const v = get(until.key)
|
|
387
|
+
if (Object.prototype.hasOwnProperty.call(until, 'is')) return String(v) === String(until.is)
|
|
388
|
+
if (Array.isArray(until.in)) return until.in.some((x) => String(x) === String(v))
|
|
389
|
+
if (Object.prototype.hasOwnProperty.call(until, 'not')) return String(v) !== String(until.not)
|
|
390
|
+
return v !== undefined && v !== null
|
|
391
|
+
}
|
|
392
|
+
|
|
210
393
|
function waitForWork(timeoutMs, opts = {}) {
|
|
211
394
|
const s = requireSession()
|
|
212
395
|
const tmap = s.hive.doc.getMap('tasks')
|
|
@@ -251,6 +434,13 @@ function waitForWork(timeoutMs, opts = {}) {
|
|
|
251
434
|
}
|
|
252
435
|
// Queued work no longer hides the mail. This returned `newChat: []` outright, which made
|
|
253
436
|
// the busiest agent in the room the one guaranteed to see no messages at all.
|
|
437
|
+
// A state the caller is waiting for, checked BEFORE anything blocks: if it is already true the
|
|
438
|
+
// agent must not sit through a timeout waiting to be told something that happened an hour ago.
|
|
439
|
+
const state = s.hive.doc.getMap('swarm_state')
|
|
440
|
+
const until = opts.until && opts.until.key ? opts.until : null
|
|
441
|
+
const stateHit = () => stateMatches(until, (k) => state.get(k))
|
|
442
|
+
if (until && stateHit()) return Promise.resolve(deliver({ tasks: accepted(), stateReached: { ...until, value: state.get(until.key) } }))
|
|
443
|
+
|
|
254
444
|
const ready = accepted()
|
|
255
445
|
if (ready.length) return Promise.resolve(deliver({ tasks: ready }))
|
|
256
446
|
// Anything already unread is handed over immediately rather than waited past — that backlog
|
|
@@ -261,7 +451,7 @@ function waitForWork(timeoutMs, opts = {}) {
|
|
|
261
451
|
}
|
|
262
452
|
return new Promise((resolve) => {
|
|
263
453
|
let done = false
|
|
264
|
-
const finish = (v) => { if (!done) { done = true; tmap.unobserve(onTask); chat.unobserve(onChat); controls.unobserve(onCtl); resolve(v) } }
|
|
454
|
+
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
455
|
const onTask = () => { const a = accepted(); if (a.length) finish(deliver({ tasks: a })) }
|
|
266
456
|
const onChat = () => {
|
|
267
457
|
const seen = box.peek(chat.toArray())
|
|
@@ -270,7 +460,11 @@ function waitForWork(timeoutMs, opts = {}) {
|
|
|
270
460
|
finish(deliver({ tasks: accepted() }))
|
|
271
461
|
}
|
|
272
462
|
const onCtl = () => { if (paused()) finish({ paused: true }) } // paused mid-wait -> stop handing out work
|
|
273
|
-
|
|
463
|
+
// A paused agent is NOT woken by state: pausing means stop picking up work, and a state flag
|
|
464
|
+
// turning green is the room offering it work. It re-checks the predicate on its next wait
|
|
465
|
+
// after a human resumes it.
|
|
466
|
+
const onState = () => { if (until && !paused() && stateHit()) finish(deliver({ tasks: accepted(), stateReached: { ...until, value: state.get(until.key) } })) }
|
|
467
|
+
tmap.observe(onTask); chat.observe(onChat); controls.observe(onCtl); if (until) state.observe(onState)
|
|
274
468
|
// On timeout, flush anything that arrived rather than dropping it: a message must never
|
|
275
469
|
// expire unread just because the wait window closed on it.
|
|
276
470
|
setTimeout(() => finish(box.hasUnread(chat.toArray()) ? deliver({ tasks: accepted() }) : null), timeoutMs)
|
|
@@ -279,13 +473,13 @@ function waitForWork(timeoutMs, opts = {}) {
|
|
|
279
473
|
|
|
280
474
|
const TOOLS = [
|
|
281
475
|
{ name: 'hive_join', description: 'Join (or host) a Hivecode room for a project folder. Joins automatically as an AI participant. If a join link is given, uses it; else reads <dir>/.hive.json; else hosts a new room. Returns the room info and the HIVE_RULES you must follow.',
|
|
282
|
-
inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'optional join link "wss://relay|room"' }, dir: { type: 'string', description: 'project folder to sync (default ./workspace)' }, name: { type: 'string', description: 'your display name in the room' }, owner: { type: 'string', description: 'the human responsible for you — only they may approve tasks directed at you' }, token: { type: 'string', description: 'access token for a secured room (else uses $HIVE_TOKEN). Required only if the relay runs in auth-required mode.' } } } },
|
|
476
|
+
inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'optional join link "wss://relay|room"' }, dir: { type: 'string', description: 'project folder to sync (default ./workspace)' }, name: { type: 'string', description: 'your display name in the room' }, owner: { type: 'string', description: 'the human responsible for you — only they may approve tasks directed at you' }, token: { type: 'string', description: 'access token for a secured room (else uses $HIVE_TOKEN). Required only if the relay runs in auth-required mode.' }, kind: { type: 'string', description: 'participant kind (ai or human)' }, passive: { type: 'boolean', description: 'true = this participant is a passive listener or bot daemon and does not take turns or answer questions' } } } },
|
|
283
477
|
{ name: 'hive_assign', description: 'Direct a task at another participant by name (e.g. ask another agent to work on a file). If the target is an AI with an owner, it stays PENDING until that owner approves.',
|
|
284
478
|
inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name to assign to' }, text: { type: 'string', description: 'what to do' } }, required: ['to', 'text'] } },
|
|
285
479
|
{ name: 'hive_read_tasks', description: 'Read tasks. Shows tasks directed at YOU (act only on accepted ones) and all pending tasks awaiting approval.',
|
|
286
480
|
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." } } } },
|
|
481
|
+
{ 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.',
|
|
482
|
+
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
483
|
{ 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
484
|
inputSchema: { type: 'object', properties: { peek: { type: 'boolean', description: 'true = look without marking messages read (default false: reading them here counts as read)' } } } },
|
|
291
485
|
{ 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 +488,28 @@ const TOOLS = [
|
|
|
294
488
|
inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
|
|
295
489
|
{ name: 'hive_complete', description: 'Mark a task you were doing as done (optionally with a note).',
|
|
296
490
|
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'] } },
|
|
491
|
+
{ 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.',
|
|
492
|
+
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'] } },
|
|
493
|
+
{ 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.',
|
|
494
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'how many of your recent addressed messages to check (default 10)' } } } },
|
|
495
|
+
{ 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.',
|
|
496
|
+
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' } } } },
|
|
497
|
+
{ 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.',
|
|
498
|
+
inputSchema: { type: 'object', properties: { all: { type: 'boolean', description: 'true = the whole room, not just yours' } } } },
|
|
499
|
+
{ 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.',
|
|
500
|
+
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
501
|
{ 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
502
|
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
503
|
{ 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
504
|
inputSchema: { type: 'object', properties: {} } },
|
|
303
505
|
{ 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
506
|
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: {
|
|
507
|
+
{ 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).',
|
|
508
|
+
inputSchema: { type: 'object', properties: {
|
|
509
|
+
region: { type: 'string', description: 'the file path (or region id) to release' },
|
|
510
|
+
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.' },
|
|
511
|
+
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.' },
|
|
512
|
+
}, required: ['region'] } },
|
|
307
513
|
{ 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
514
|
inputSchema: { type: 'object', properties: {} } },
|
|
309
515
|
{ 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.',
|
|
@@ -432,14 +638,17 @@ function enforceDcoLock(opts = {}) {
|
|
|
432
638
|
// itself reporting the wrong build. Read the truth instead; package.json always ships in the
|
|
433
639
|
// tarball and sits beside this file in both the repo and the published package.
|
|
434
640
|
// A failed read must never stop the server from starting, so it falls back rather than throwing.
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const server = new Server(
|
|
641
|
+
import { VERSION } from "./hive-version.js"
|
|
642
|
+
export { VERSION }
|
|
643
|
+
// `instructions` is placed in the agent's SYSTEM PROMPT by the client at connect time, which
|
|
644
|
+
// makes it the only channel that reaches an agent before its first tool call. Everything else we
|
|
645
|
+
// have -- tool descriptions, the HIVE_RULES returned by hive_join -- is read too late or scrolls
|
|
646
|
+
// away, and the rule that matters most (hive_wait is your idle state; ending your turn makes you
|
|
647
|
+
// unreachable) is exactly the one an agent needs up front. See hive-instructions.js.
|
|
648
|
+
const server = new Server(
|
|
649
|
+
{ name: 'hivecode', version: VERSION },
|
|
650
|
+
{ capabilities: { tools: {} }, instructions: INSTRUCTIONS },
|
|
651
|
+
)
|
|
443
652
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
|
|
444
653
|
async function dispatch(req) {
|
|
445
654
|
const { name, arguments: args = {} } = req.params
|
|
@@ -449,9 +658,32 @@ async function dispatch(req) {
|
|
|
449
658
|
// persisted session exists for this cwd, transparently rejoin before any tool runs —
|
|
450
659
|
// the agent (and its human) never see a "not in a room" flake again.
|
|
451
660
|
if (!session && name !== 'hive_join' && name !== 'hive_leave') await tryResumeSession()
|
|
661
|
+
// HIV-P11. STAMPED BEFORE THE TOOL RUNS, not after it returns.
|
|
662
|
+
//
|
|
663
|
+
// The first version stamped in a `finally` -- which is wrong by exactly one call, and the
|
|
664
|
+
// suite caught it: an agent that called hive_members saw ITSELF reported PARKED, because the
|
|
665
|
+
// record it read still held the timestamp of its previous call. The activity is the call
|
|
666
|
+
// BEGINNING; a tool that takes four minutes is working for all four of them, and a tool that
|
|
667
|
+
// throws was still an agent doing something. Stamping first covers both, and covers every
|
|
668
|
+
// tool anyone adds later, because there is no path into the switch that skips this line.
|
|
669
|
+
//
|
|
670
|
+
// hive_wait stamps its own entry and exit (waitingSince must mean "inside the call RIGHT
|
|
671
|
+
// NOW"). hive_leave drops the row instead of refreshing it. hive_join has no session yet, so
|
|
672
|
+
// it is stamped in the finally below -- the one case where after is the only option.
|
|
673
|
+
if (session && name !== 'hive_wait' && name !== 'hive_leave') {
|
|
674
|
+
try { ACT.noteAction(activityMap(), { name: session.name, tool: name }) } catch { }
|
|
675
|
+
}
|
|
452
676
|
switch (name) {
|
|
453
677
|
case 'hive_join': return text(await joinRoom(args))
|
|
454
678
|
case 'hive_say': {
|
|
679
|
+
// THE CONNECTION IS CHECKED BEFORE ANYTHING ELSE. Until today this tool reported
|
|
680
|
+
// "sent to @Builder: ..." with the socket down, which is a false success in the most
|
|
681
|
+
// literal sense: the bytes are in a local Yjs doc, the relay has never seen them, and the
|
|
682
|
+
// word the agent reads is the same word it reads on success. status().connected was
|
|
683
|
+
// right there and free the whole time.
|
|
684
|
+
const sendConn = (() => { try { return requireSession().hive.status() } catch { return null } })()
|
|
685
|
+
const sendWarn = RC.sendWarning({ connected: !sendConn || !!sendConn.connected })
|
|
686
|
+
const stamp = (t) => (sendWarn ? `${sendWarn}\n\n${t}` : t)
|
|
455
687
|
const raw = String(args.text || '')
|
|
456
688
|
// The marker is in the text itself so it survives into HIVE_CHAT.md and is visible to
|
|
457
689
|
// a human reading the log, not just to the recipient's tool output.
|
|
@@ -463,12 +695,164 @@ async function dispatch(req) {
|
|
|
463
695
|
// Immediate, per-call feedback, at the moment the mistake is made.
|
|
464
696
|
const named = mentionedNames(body)
|
|
465
697
|
if (!named.length) {
|
|
466
|
-
return text(`sent: ${body}
|
|
698
|
+
return text(stamp(`sent: ${body}`) + `\n\nNOBODY WAS ADDRESSED. This went to every agent's "room ` +
|
|
467
699
|
`activity", where they are told to skip it — so assume no one acts on it. Name the ` +
|
|
468
700
|
`agents you actually need with @Name (hive_members lists who is here). Use an ` +
|
|
469
701
|
`unaddressed message only for a genuine announcement to the whole room.`)
|
|
470
702
|
}
|
|
471
|
-
|
|
703
|
+
// WHO IS ACTUALLY GOING TO READ THIS. The sender is the only participant who can act on
|
|
704
|
+
// "they left" or "they are mid-task", and it is worth strictly more here than in a
|
|
705
|
+
// reply: it arrives before the sender starts waiting, and it costs the room no message.
|
|
706
|
+
// This is the whole reason busy is state rather than an auto-reply (see hive-busy.js).
|
|
707
|
+
const s = requireSession()
|
|
708
|
+
const members = s.hive.members()
|
|
709
|
+
const memberMap = new Map(members.map((m) => [String(m.name).toLowerCase(), m]))
|
|
710
|
+
const present = new Set(members.map((m) => String(m.name).toLowerCase()))
|
|
711
|
+
const busyMap = s.hive.doc.getMap('busy')
|
|
712
|
+
const act = activityMap()
|
|
713
|
+
const controls = s.hive.doc.getMap('controls')
|
|
714
|
+
const parkedAfter = ACT.parkedAfterMs()
|
|
715
|
+
const now = Date.now()
|
|
716
|
+
|
|
717
|
+
const everHere = new Set(present)
|
|
718
|
+
for (const m of s.hive.doc.getArray('chat').toArray()) {
|
|
719
|
+
if (m && m.by) everHere.add(String(m.by).toLowerCase())
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// HIV-P11b. Send-time inspection of recipient state:
|
|
723
|
+
// Warns when addressing PARKED, PAUSED, PASSIVE, or GONE/AWAY agents.
|
|
724
|
+
function describeRecipientLiveness(n) {
|
|
725
|
+
const lc = n.toLowerCase()
|
|
726
|
+
const mem = memberMap.get(lc)
|
|
727
|
+
const isPresent = !!mem
|
|
728
|
+
const rec = act ? act.get(lc) : null
|
|
729
|
+
const c = controls.get(mem ? mem.name : n)
|
|
730
|
+
const isPaused = !!(c && c.state === 'paused')
|
|
731
|
+
const isPassive = !!(mem && mem.passive)
|
|
732
|
+
const d = ACT.describe(rec, {
|
|
733
|
+
present: isPresent,
|
|
734
|
+
paused: isPaused,
|
|
735
|
+
passive: isPassive,
|
|
736
|
+
now,
|
|
737
|
+
parkedAfter,
|
|
738
|
+
})
|
|
739
|
+
const displayName = mem ? mem.name : n
|
|
740
|
+
|
|
741
|
+
if (d.state === ACT.PARKED) {
|
|
742
|
+
const age = d.ageMs == null ? 'its whole session' : `${Math.round(d.ageMs / 60000)} min`
|
|
743
|
+
const tool = d.lastTool ? ` (${d.lastTool})` : ''
|
|
744
|
+
return `⚠ @${displayName} — PARKED: connected, but NOT inside hive_wait and silent for ${age}${tool}. ` +
|
|
745
|
+
`It will not wake on its own — anything you send it now will sit unread until a human prompts it.`
|
|
746
|
+
}
|
|
747
|
+
if (d.state === ACT.PAUSED) {
|
|
748
|
+
return `⚠ @${displayName} — PAUSED by a human. It is obeying a stop, not stalled. Do not reassign its work.`
|
|
749
|
+
}
|
|
750
|
+
if (d.state === ACT.PASSIVE) {
|
|
751
|
+
return `⚠ @${displayName} — PASSIVE LISTENER. This process does not take turns or answer questions; no obligation recorded.`
|
|
752
|
+
}
|
|
753
|
+
if (d.state === ACT.GONE) {
|
|
754
|
+
if (everHere.has(lc)) {
|
|
755
|
+
return `⚠ @${displayName} — AWAY RIGHT NOW. Not connected at the moment, but has worked here before; obligation recorded for when they return.`
|
|
756
|
+
} else {
|
|
757
|
+
return `⚠ @${displayName} — NOT IN THIS ROOM. Nobody by that name is here, so nobody is going to answer. Call hive_members and re-address it now; this is the one failure mode that otherwise costs you a whole shift.`
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// d.state is WORKING or WAITING
|
|
761
|
+
const busy = findBusy(busyMap, n)
|
|
762
|
+
if (busy) {
|
|
763
|
+
return describeRecipient(displayName, busy, true, now)
|
|
764
|
+
}
|
|
765
|
+
return ''
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const candidateNames = named.filter((n) => n.toLowerCase() !== 'all' && n.toLowerCase() !== s.name.toLowerCase())
|
|
769
|
+
const seenCandidates = new Set()
|
|
770
|
+
const uniqueCandidates = []
|
|
771
|
+
for (const n of candidateNames) {
|
|
772
|
+
const lc = n.toLowerCase()
|
|
773
|
+
if (!seenCandidates.has(lc)) {
|
|
774
|
+
seenCandidates.add(lc)
|
|
775
|
+
uniqueCandidates.push(n)
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
const notes = uniqueCandidates.map(describeRecipientLiveness).filter(Boolean)
|
|
779
|
+
const to = mentionsAll(body) ? 'the whole room (@all)' : named.map((n) => '@' + n).join(', ')
|
|
780
|
+
// NEEDS REPLY. A question is recorded, not just spoken. One obligation per agent named.
|
|
781
|
+
// HIV-P6: If the room knows nobody will answer, DO NOT record a debt.
|
|
782
|
+
// 1. Absent names: warned immediately, ZERO obligations opened.
|
|
783
|
+
// 2. Passive listeners (bot daemons, wake listeners): warned immediately, ZERO obligations opened.
|
|
784
|
+
// 3. Active participants: real debt opened, dischargeable ONLY by the asker.
|
|
785
|
+
// 4. @all broadcast: targets only active non-passive AI agents in the room.
|
|
786
|
+
let obStr = ''
|
|
787
|
+
if (args.needsReply) {
|
|
788
|
+
const obMap = s.hive.doc.getMap('obligations')
|
|
789
|
+
const dueMs = Math.max(1000, (Number(args.replyWithinMinutes) || 10) * 60000)
|
|
790
|
+
|
|
791
|
+
let activeTargets = []
|
|
792
|
+
let awayTargets = []
|
|
793
|
+
let neverSeenNames = []
|
|
794
|
+
let passiveNames = []
|
|
795
|
+
|
|
796
|
+
if (mentionsAll(body)) {
|
|
797
|
+
activeTargets = members
|
|
798
|
+
.filter((m) => String(m.name).toLowerCase() !== s.name.toLowerCase() && !m.passive && m.kind === 'ai')
|
|
799
|
+
.map((m) => String(m.name))
|
|
800
|
+
} else {
|
|
801
|
+
for (const n of uniqueCandidates) {
|
|
802
|
+
const lc = n.toLowerCase()
|
|
803
|
+
const mem = memberMap.get(lc)
|
|
804
|
+
if (mem) {
|
|
805
|
+
if (mem.passive) {
|
|
806
|
+
passiveNames.push(mem.name || n)
|
|
807
|
+
} else {
|
|
808
|
+
activeTargets.push(mem.name || n)
|
|
809
|
+
}
|
|
810
|
+
} else {
|
|
811
|
+
if (everHere.has(lc)) {
|
|
812
|
+
awayTargets.push(n)
|
|
813
|
+
} else {
|
|
814
|
+
neverSeenNames.push(n)
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const openedActive = activeTargets.map((n) => OB.open(obMap, {
|
|
821
|
+
from: s.name, to: n, text: body, dueMs,
|
|
822
|
+
absent: false,
|
|
823
|
+
neverSeen: false,
|
|
824
|
+
}))
|
|
825
|
+
|
|
826
|
+
const openedAway = awayTargets.map((n) => OB.open(obMap, {
|
|
827
|
+
from: s.name, to: n, text: body, dueMs,
|
|
828
|
+
absent: true,
|
|
829
|
+
neverSeen: false,
|
|
830
|
+
}))
|
|
831
|
+
|
|
832
|
+
const opened = [...openedActive, ...openedAway]
|
|
833
|
+
|
|
834
|
+
if (opened.length) {
|
|
835
|
+
obStr += `\n\nYOU ARE OWED AN ANSWER — ${opened.map((o) => `${o.id} (${o.to})`).join(', ')}\n` +
|
|
836
|
+
`This stays open until YOU call hive_answered. The agent you asked cannot close it, and ` +
|
|
837
|
+
`a reply does not close it either — you decide whether you were actually answered. If it ` +
|
|
838
|
+
`is still open in ${Math.round(dueMs / 60000)} min you will be reminded on your next hive_wait.`
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return text(stamp(`sent to ${to}: ${body}`) + (notes.length ? '\n\n' + notes.join('\n') : '') + obStr)
|
|
842
|
+
}
|
|
843
|
+
case 'hive_busy': {
|
|
844
|
+
const s = requireSession()
|
|
845
|
+
const busyMap = s.hive.doc.getMap('busy')
|
|
846
|
+
if (args.clear) { busyMap.delete(s.name); return text('busy cleared — the room will expect normal replies from you again') }
|
|
847
|
+
const e = makeBusy(args.what, { ttl: args.minutes ? args.minutes * 60000 : undefined })
|
|
848
|
+
busyMap.set(s.name, e)
|
|
849
|
+
// Say what it does NOT do, because an agent that believes this silences its mail will
|
|
850
|
+
// stop calling hive_check and go deaf on purpose.
|
|
851
|
+
return text(`marked busy: ${e.what}\n\nThis posts no message and silences nothing — you still ` +
|
|
852
|
+
`receive mail and must still call hive_check between edits. It only tells anyone who ` +
|
|
853
|
+
`@mentions you, at the moment they send, that you are mid-task. It expires by itself in ` +
|
|
854
|
+
`${Math.round(e.ttl / 60000)} minutes, so a crash cannot leave the room believing you are ` +
|
|
855
|
+
`permanently busy. Call hive_busy with clear:true when you finish.`)
|
|
472
856
|
}
|
|
473
857
|
case 'hive_read_chat': {
|
|
474
858
|
const me = requireSession().name
|
|
@@ -496,7 +880,22 @@ async function dispatch(req) {
|
|
|
496
880
|
const got = args.peek ? box.peek(all) : box.take(all)
|
|
497
881
|
if (!args.peek) persistCursor()
|
|
498
882
|
const tasks = [...s.hive.doc.getMap('tasks').values()].filter((t) => t.to === s.name && t.status === 'accepted')
|
|
499
|
-
|
|
883
|
+
// WHO IS STILL WAITING ON YOU. Read mail is not answered mail: the cursor advances the
|
|
884
|
+
// moment a message is handed over, so an agent can absorb a direct question, get busy,
|
|
885
|
+
// and never notice it left someone hanging -- which is the failure that made a previous
|
|
886
|
+
// room's PM give up on this channel and tail the chat file with a shell script instead.
|
|
887
|
+
// Derived from the log, so it needs no state and cannot drift.
|
|
888
|
+
const owed = unansweredFor(all, s.name)
|
|
889
|
+
const owedLine = owed.length
|
|
890
|
+
? `\n\n⏳ STILL WAITING ON A REPLY FROM YOU: ${owed.map((o) => '@' + o.from).join(', ')}. ` +
|
|
891
|
+
`Answer with hive_say (name them), even if the answer is "not yet, I am on X" — ` +
|
|
892
|
+
`silence is the one thing they cannot act on.`
|
|
893
|
+
: ''
|
|
894
|
+
if (!got.unreadCount && !tasks.length) {
|
|
895
|
+
return text(owed.length
|
|
896
|
+
? `✓ no new mail and no approved work.${owedLine}`
|
|
897
|
+
: '✓ nothing waiting — nobody is calling you and you have no approved work. Carry on.')
|
|
898
|
+
}
|
|
500
899
|
const parts = []
|
|
501
900
|
if (got.priority === 'stop') {
|
|
502
901
|
// HANDLING URGENT MAIL COUNTS AS LIVENESS.
|
|
@@ -529,7 +928,7 @@ async function dispatch(req) {
|
|
|
529
928
|
const dig = ambientDigest(ambient)
|
|
530
929
|
if (dig) parts.push(dig)
|
|
531
930
|
if (tasks.length) parts.push(`approved work queued for you:\n${tasks.map(fmtTask).join('\n')}`)
|
|
532
|
-
return text(parts.join('\n\n'))
|
|
931
|
+
return text(parts.join('\n\n') + owedLine)
|
|
533
932
|
}
|
|
534
933
|
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
934
|
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 +940,37 @@ async function dispatch(req) {
|
|
|
541
940
|
}
|
|
542
941
|
case 'hive_wait': {
|
|
543
942
|
const ms = Math.min(Math.max(Number(args.timeoutSeconds) || 60, 1), 300) * 1000
|
|
544
|
-
|
|
943
|
+
// HIV-P11. `waitingSince` means "inside this call right now", never "waited at some
|
|
944
|
+
// point", and the finally is what keeps that true. A wait that returns or throws while
|
|
945
|
+
// leaving the flag set describes an agent parked in a call it has already left -- and
|
|
946
|
+
// describes it as WAITING, the single most reassuring state in the taxonomy. stateOf()
|
|
947
|
+
// gates WAITING on awareness so a process that DIES here cannot fake it, but a live
|
|
948
|
+
// process that merely threw is still present, so the gate does not cover that and this
|
|
949
|
+
// does. The name is captured first because the session can be gone by the finally.
|
|
950
|
+
const waitName = requireSession().name
|
|
951
|
+
ACT.noteWaitStart(activityMap(), { name: waitName })
|
|
952
|
+
let r
|
|
953
|
+
try {
|
|
954
|
+
r = await waitForWork(ms, { wakeOn: args.wakeOn, until: args.until })
|
|
955
|
+
} finally {
|
|
956
|
+
try { ACT.noteWaitEnd(activityMap(), { name: waitName }) } catch { }
|
|
957
|
+
}
|
|
545
958
|
const globalState = requireSession().hive.getState()
|
|
546
|
-
|
|
959
|
+
// THE STATE BLOCK WAS RETURNED ON EVERY SINGLE WAIT, unchanged, forever -- an agent
|
|
960
|
+
// sitting in the night-shift loop was handed the same JSON hundreds of times and paid for
|
|
961
|
+
// it each time. It is now sent when the agent has not seen it, when it CHANGED (which
|
|
962
|
+
// always wins: a suppressed state change is a wrong answer, not a saving), or when enough
|
|
963
|
+
// time AND enough output have passed that it is probably no longer in context.
|
|
964
|
+
const stateBody = Object.keys(globalState).length ? `\n[GLOBAL PROJECT STATE]\n${JSON.stringify(globalState, null, 2)}\n` : ''
|
|
965
|
+
const stateStr = stateBody && throttle.send('state', stateBody) ? stateBody : ''
|
|
966
|
+
// The room rules went the other way: 6 KB handed over ONCE in the hive_join result, where
|
|
967
|
+
// compaction reaches them first. They are the ROOM-specific half -- lanes, gate command,
|
|
968
|
+
// ownership -- so the general rules in the system prompt do not cover them. A pointer is
|
|
969
|
+
// re-sent, never a copy: the file is on this agent's own disk, and a second summary here
|
|
970
|
+
// would be one more description to drift out of date.
|
|
971
|
+
const rulesStr = throttle.should('rules', RULES_NUDGE).why === 'likely-compacted'
|
|
972
|
+
? (throttle.sent('rules', RULES_NUDGE), `\n${RULES_NUDGE}\n`)
|
|
973
|
+
: ''
|
|
547
974
|
// Connection state, reported ONLY when it is bad. An agent that cannot tell a
|
|
548
975
|
// quiet room from a dead socket reaches for hive_join -- which calls stop() and
|
|
549
976
|
// tears the session down, turning a blip that would have healed itself into a
|
|
@@ -558,6 +985,25 @@ async function dispatch(req) {
|
|
|
558
985
|
: ''
|
|
559
986
|
|
|
560
987
|
const me = requireSession().name
|
|
988
|
+
// THE KNOCK RIDES THE WAIT. hive_wait is the agent's idle state and a blocked wait costs
|
|
989
|
+
// zero tokens, so a reminder delivered here arrives on a call that was going to happen
|
|
990
|
+
// anyway. Agent Relay re-injects its boomerang on a broker timer, which spends a turn on
|
|
991
|
+
// every knock; this spends nothing. The reminder is deliberately NOT sent to a paused
|
|
992
|
+
// agent -- paused means stop, and nagging a stopped agent is just noise.
|
|
993
|
+
const obMap = (() => { try { return requireSession().hive.doc.getMap('obligations') } catch { return null } })()
|
|
994
|
+
let owedStr = ''
|
|
995
|
+
if (obMap) {
|
|
996
|
+
const due = OB.overdue(obMap, me)
|
|
997
|
+
if (due.length) {
|
|
998
|
+
// Only a DELIVERED knock moves the deadline. If merely NOTICING an overdue record
|
|
999
|
+
// rescheduled it, an agent that never calls hive_wait would have its deadline pushed
|
|
1000
|
+
// out forever by whoever happened to read the board -- exactly backwards.
|
|
1001
|
+
for (const o of due) OB.knock(obMap, o.id)
|
|
1002
|
+
owedStr = `\n[YOU OWE ${due.length} ANSWER(S)] These were asked of you and are still open. ` +
|
|
1003
|
+
`Answer in chat with @Name — you cannot close them yourself, the asker does that.\n` +
|
|
1004
|
+
due.map((o) => ' ' + OB.describe(o)).join('\n') + '\n'
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
561
1007
|
// Direct address is separated from ambient traffic on every path. It used to be that
|
|
562
1008
|
// only a PAUSED agent got this distinction, so a working agent could not tell "stop,
|
|
563
1009
|
// wrong file" from two other agents talking to each other.
|
|
@@ -577,13 +1023,103 @@ async function dispatch(req) {
|
|
|
577
1023
|
}
|
|
578
1024
|
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
1025
|
}
|
|
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}`)
|
|
1026
|
+
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
1027
|
const parts = []
|
|
582
1028
|
if (r.resumed) parts.push('▶ You were RESUMED by mission control — continue.')
|
|
583
1029
|
const mb = mentionBlock(r); if (mb) parts.push(mb)
|
|
584
1030
|
if (r.tasks.length) parts.push(`APPROVED WORK — do these now, then hive_complete each:\n${r.tasks.map(fmtTask).join('\n')}`)
|
|
585
1031
|
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.)'))
|
|
1032
|
+
return text(`${owedStr}${connStr}${stateStr}${rulesStr}\n` + (parts.join('\n\n') || '(resumed — no queued work yet. Call hive_wait again.)'))
|
|
1033
|
+
}
|
|
1034
|
+
case 'hive_delivered': {
|
|
1035
|
+
const s2 = requireSession()
|
|
1036
|
+
const conn = (() => { try { return s2.hive.status() } catch { return null } })()
|
|
1037
|
+
const connected = !conn || !!conn.connected
|
|
1038
|
+
const here = new Set(s2.hive.members().map((m) => String(m.name).toLowerCase()))
|
|
1039
|
+
const rs = RC.receipts({
|
|
1040
|
+
chat: s2.hive.doc.getArray('chat').toArray(),
|
|
1041
|
+
cursors: s2.hive.doc.getMap('cursors'),
|
|
1042
|
+
me: s2.name,
|
|
1043
|
+
present: (n) => here.has(String(n).toLowerCase()),
|
|
1044
|
+
connected,
|
|
1045
|
+
limit: Math.min(Math.max(Number(args.limit) || 10, 1), 50),
|
|
1046
|
+
})
|
|
1047
|
+
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.)')
|
|
1048
|
+
const states = rs.flatMap((r) => r.to)
|
|
1049
|
+
const read = RC.countDelivered(states)
|
|
1050
|
+
const unknown = states.filter((x) => x.state === RC.UNKNOWN).length
|
|
1051
|
+
// The headline deliberately reports READ over TOTAL and names UNKNOWN separately rather
|
|
1052
|
+
// than folding it in. Summing "not proven undelivered" into the good number is how a
|
|
1053
|
+
// receipt system starts reassuring instead of measuring.
|
|
1054
|
+
const head = `${read}/${states.length} confirmed READ` +
|
|
1055
|
+
(unknown ? ` — ${unknown} UNKNOWN (no read position published; that is an absence of evidence, not evidence of delivery)` : '')
|
|
1056
|
+
const worst = states.filter((x) => x.state !== RC.READ)
|
|
1057
|
+
return text(`${head}\n\n` + rs.map((r) => ' ' + RC.describe(r)).join('\n') +
|
|
1058
|
+
(worst.length ? '\n\n' + [...new Set(worst.map((x) => `${x.name}: ${x.why}`))].join('\n') : ''))
|
|
1059
|
+
}
|
|
1060
|
+
case 'hive_answered': {
|
|
1061
|
+
const s2 = requireSession()
|
|
1062
|
+
const obMap = s2.hive.doc.getMap('obligations')
|
|
1063
|
+
if (args.from) {
|
|
1064
|
+
const closed = OB.dischargeFrom(obMap, { by: s2.name, to: String(args.from) })
|
|
1065
|
+
if (!closed.length) return text(`Nothing open that you asked @${args.from}. hive_owed shows what is.`)
|
|
1066
|
+
return text(`Closed ${closed.length} question(s) you asked @${args.from}:\n` + closed.map((o) => ' ' + o.text).join('\n'))
|
|
1067
|
+
}
|
|
1068
|
+
if (!args.id) return err('hive_answered needs either id (one question) or from (everything you asked one agent). hive_owed lists both.')
|
|
1069
|
+
const r2 = OB.discharge(obMap, { id: String(args.id), by: s2.name })
|
|
1070
|
+
if (!r2.ok) return err(r2.reason)
|
|
1071
|
+
return text(r2.already ? `${args.id} was already closed.` : `Closed ${args.id}: "${r2.rec.text}"`)
|
|
1072
|
+
}
|
|
1073
|
+
case 'hive_owed': {
|
|
1074
|
+
const s2 = requireSession()
|
|
1075
|
+
const obMap = s2.hive.doc.getMap('obligations')
|
|
1076
|
+
const act = activityMap()
|
|
1077
|
+
const parkedAfter = ACT.parkedAfterMs()
|
|
1078
|
+
const members = s2.hive.members()
|
|
1079
|
+
const memberMap = new Map(members.map((m) => [String(m.name).toLowerCase(), m]))
|
|
1080
|
+
const controls = s2.hive.doc.getMap('controls')
|
|
1081
|
+
const now = Date.now()
|
|
1082
|
+
|
|
1083
|
+
// HIV-P11b. Dynamic reachability annotation for debts:
|
|
1084
|
+
// Distinguishes actionable pending debts from debts owed by PARKED, GONE, or PAUSED agents.
|
|
1085
|
+
function describeDebt(o) {
|
|
1086
|
+
const base = OB.describe(o, () => now)
|
|
1087
|
+
const lc = String(o.to || '').toLowerCase()
|
|
1088
|
+
const mem = memberMap.get(lc)
|
|
1089
|
+
const rec = act ? act.get(lc) : null
|
|
1090
|
+
const c = controls.get(mem ? mem.name : o.to)
|
|
1091
|
+
const d = ACT.describe(rec, {
|
|
1092
|
+
present: !!mem,
|
|
1093
|
+
paused: !!(c && c.state === 'paused'),
|
|
1094
|
+
passive: !!(mem && mem.passive),
|
|
1095
|
+
now,
|
|
1096
|
+
parkedAfter,
|
|
1097
|
+
})
|
|
1098
|
+
if (d.state === ACT.PARKED) {
|
|
1099
|
+
const age = d.ageMs == null ? 'its whole session' : `${Math.round(d.ageMs / 60000)} min`
|
|
1100
|
+
return `${base} [UNREACHABLE — @${mem ? mem.name : o.to} is PARKED: silent for ${age}, will not wake on its own]`
|
|
1101
|
+
}
|
|
1102
|
+
if (d.state === ACT.GONE) {
|
|
1103
|
+
return `${base} [UNREACHABLE — @${o.to} is GONE: not connected]`
|
|
1104
|
+
}
|
|
1105
|
+
if (d.state === ACT.PAUSED) {
|
|
1106
|
+
return `${base} [PAUSED by human — do not reassign]`
|
|
1107
|
+
}
|
|
1108
|
+
return base
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (args.all) {
|
|
1112
|
+
const openList = OB.openList(obMap, () => now)
|
|
1113
|
+
if (!openList.length) return text('Nothing outstanding in this room.')
|
|
1114
|
+
const staleCount = openList.filter(OB.isStale).length
|
|
1115
|
+
return text(`${openList.length} open, ${staleCount} stale:\n` + openList.map((o) => ' ' + describeDebt(o)).join('\n'))
|
|
1116
|
+
}
|
|
1117
|
+
const mine = OB.owedBy(obMap, s2.name, () => now), theirs = OB.owedTo(obMap, s2.name, () => now)
|
|
1118
|
+
if (!mine.length && !theirs.length) return text('You owe nothing and are owed nothing. (Pass all:true for the whole room.)')
|
|
1119
|
+
const parts = []
|
|
1120
|
+
if (mine.length) parts.push(`YOU OWE ${mine.length} ANSWER(S) — answer in chat with @Name; the asker closes it:\n` + mine.map((o) => ' ' + describeDebt(o)).join('\n'))
|
|
1121
|
+
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) => ' ' + describeDebt(o)).join('\n'))
|
|
1122
|
+
return text(parts.join('\n\n'))
|
|
587
1123
|
}
|
|
588
1124
|
case 'hive_set_state': { requireSession().hive.setState(String(args.key), String(args.val)); return text(`Set global state [${args.key}] to [${args.val}]`) }
|
|
589
1125
|
case 'hive_read_state': { return text(requireSession().hive.getState()) }
|
|
@@ -601,9 +1137,29 @@ async function dispatch(req) {
|
|
|
601
1137
|
case 'hive_verified': {
|
|
602
1138
|
const f = String(args.file || ''); if (!f) return err('need a file path')
|
|
603
1139
|
const r = requireSession().hive.verified(f)
|
|
604
|
-
|
|
1140
|
+
// Attestation and gate verdicts are independent records, so an unattested path must still
|
|
1141
|
+
// report its gate. Returning early here would hide a FAILED check behind "nobody measured
|
|
1142
|
+
// the bytes", which is the opposite of what this tool is for.
|
|
1143
|
+
if (!r.count) {
|
|
1144
|
+
const g0 = requireSession().hive.gateVerdicts(f)
|
|
1145
|
+
const extra = Object.entries(g0).map(([n, v]) => v.forced
|
|
1146
|
+
? `\n [!] ${n} FORCE-released it without passing the gate${v.why ? ` — "${v.why}"` : ''}`
|
|
1147
|
+
: `\n ${v.pass ? '[ok]' : '[!]'} ${n}'s gate ${v.pass ? 'passed' : `FAILED (exit ${v.exit})`}`).join('')
|
|
1148
|
+
return text(`${f}: NOT attested by anyone. Unverified — measure it and call hive_attest.${extra}`)
|
|
1149
|
+
}
|
|
605
1150
|
const who = Object.entries(r.peers).map(([n, v]) => `${n}: ${v.bytes} bytes / ${v.lines} lines at ${v.at}`).join('\n ')
|
|
606
|
-
|
|
1151
|
+
// Same bytes in two folders says the file PROPAGATED. It says nothing about whether it
|
|
1152
|
+
// WORKS, which is the actual question an agent is asking here — so the gate verdicts
|
|
1153
|
+
// belong in this answer. A file two peers agree on and whose owner's tests failed is the
|
|
1154
|
+
// exact thing somebody is about to build on.
|
|
1155
|
+
const g = requireSession().hive.gateVerdicts(f)
|
|
1156
|
+
const gLines = Object.entries(g).map(([n, v]) => v.forced
|
|
1157
|
+
? ` [!] ${n} FORCE-released it without passing the gate${v.why ? ` — "${v.why}"` : ' and gave no reason'}`
|
|
1158
|
+
: ` ${v.pass ? '[ok]' : '[!]'} ${n}'s gate ${v.pass ? 'passed' : `FAILED (exit ${v.exit})`}: ${v.cmd}`)
|
|
1159
|
+
const gate = gLines.length
|
|
1160
|
+
? `\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.' : ''}`
|
|
1161
|
+
: '\n\nNo gate verdict recorded for this path — nobody has run a check on it here.'
|
|
1162
|
+
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
1163
|
}
|
|
608
1164
|
case 'hive_approve': { const r = requireSession().hive.decide(String(args.id), true); return r.ok ? text(`approved ${args.id}`) : err(r.error) }
|
|
609
1165
|
case 'hive_deny': { const r = requireSession().hive.decide(String(args.id), false); return r.ok ? text(`denied ${args.id}`) : err(r.error) }
|
|
@@ -614,18 +1170,84 @@ async function dispatch(req) {
|
|
|
614
1170
|
// always a mistake, and refusing here is far cheaper than refusing after the agent has
|
|
615
1171
|
// built the content it intended to write.
|
|
616
1172
|
enforceDcoLock({ path: region });
|
|
617
|
-
|
|
618
|
-
|
|
1173
|
+
// HIV-27: A CLAIM YOU CANNOT PUBLISH IS NOT A CLAIM. The claim is a path string in the
|
|
1174
|
+
// shared map; whether that path's BYTES sync is decided separately, per client. Nothing
|
|
1175
|
+
// compared them, so an agent could hold an exclusive claim on a file nobody else can
|
|
1176
|
+
// see, do the work, pass a gate running in its own folder where the file is real, and
|
|
1177
|
+
// hand back something that never existed for anyone else -- every signal green.
|
|
1178
|
+
// Refused HERE rather than at release: by release time the work is already built and
|
|
1179
|
+
// all we could do is tell the agent it was wasted.
|
|
1180
|
+
const unshared = requireSession().hive.whyNotShared(region)
|
|
1181
|
+
if (unshared) {
|
|
1182
|
+
return err(`refusing to claim ${region} — ${unshared}
|
|
1183
|
+
|
|
1184
|
+
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.`)
|
|
1185
|
+
}
|
|
1186
|
+
// SETTLED, not optimistic. The synchronous claim answers from this replica alone and
|
|
1187
|
+
// tells three simultaneous callers "it's yours" (measured 5/5). An agent then edits
|
|
1188
|
+
// behind a lock it does not hold. This waits for the relay to serialise it.
|
|
1189
|
+
const got = await requireSession().hive.claimSettled(region, String(args.intent || 'edit'))
|
|
1190
|
+
if (got) {
|
|
1191
|
+
// HIV-P18. "It's yours" was all this said, and for a file outside the working set that
|
|
1192
|
+
// was the entire defect: the agent reads its directory, sees nothing, concludes the file
|
|
1193
|
+
// does not exist, and CREATES it -- which is the blind recreate. Measured 21 Sep 2026:
|
|
1194
|
+
// claim returned true, the file doc was never opened, no bytes arrived, and the agent
|
|
1195
|
+
// was told it held something it could not see.
|
|
1196
|
+
//
|
|
1197
|
+
// sync.js now fetches on claim. This reports WHICH of three states the agent is in,
|
|
1198
|
+
// because they demand different next actions and an empty directory cannot tell them
|
|
1199
|
+
// apart. Awaited here rather than in claim(), whose contract is synchronous.
|
|
1200
|
+
let fetched = null
|
|
1201
|
+
try { fetched = await requireSession().hive.fetch(region) } catch { }
|
|
1202
|
+
const head = `claimed ${region} — it's yours.`
|
|
1203
|
+
if (fetched && fetched.notInRoom) {
|
|
1204
|
+
return text(`${head}
|
|
1205
|
+
|
|
1206
|
+
!! ${region} IS NOT IN THIS ROOM. No client has ever published it, so nothing will fetch it and it is NOT simply outside your working set. If you create it now you are creating a new file, not editing an existing one — which is correct only if that is what you intend. If you expected content here, the path is probably outside the room's sync scope: check that first, then ask whoever holds the file to write it into their synced folder.
|
|
1207
|
+
|
|
1208
|
+
Edit it, then call hive_release.`)
|
|
1209
|
+
}
|
|
1210
|
+
if (fetched && fetched.ok === false) {
|
|
1211
|
+
return text(`${head}
|
|
1212
|
+
|
|
1213
|
+
!! ${region} IS IN THE ROOM BUT ITS CONTENT HAS NOT ARRIVED. ${fetched.reason || ''}
|
|
1214
|
+
DO NOT create it from scratch — the room's copy exists and writing over it unions two whole files. Retry, or check hive_status (filesHeld of roomFiles).
|
|
1215
|
+
|
|
1216
|
+
Then call hive_release.`)
|
|
1217
|
+
}
|
|
1218
|
+
const note = fetched && fetched.ok && !fetched.alreadyLocal && !fetched.empty
|
|
1219
|
+
? ` Its content was not in this folder and has been fetched (${fetched.bytes} bytes) — read it before editing.`
|
|
1220
|
+
: ''
|
|
1221
|
+
return text(`claimed ${region} — it's yours.${note} Edit it, then call hive_release.`)
|
|
1222
|
+
}
|
|
619
1223
|
const c = requireSession().hive.senseClaim(region)
|
|
620
1224
|
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
1225
|
}
|
|
622
|
-
case 'hive_release': {
|
|
1226
|
+
case 'hive_release': {
|
|
1227
|
+
const region = String(args.region || '')
|
|
1228
|
+
if (!region) return err('need a region')
|
|
1229
|
+
// A gated release can REFUSE, and reporting "released" either way is the worst thing this
|
|
1230
|
+
// could do: the agent moves on believing it handed the file back while it still holds it.
|
|
1231
|
+
const r = requireSession().hive.release(region, { force: !!args.force, why: String(args.why || '') })
|
|
1232
|
+
if (r && r.ok === false) return text(r.message)
|
|
1233
|
+
if (r && r.forced) return text(`released ${region} WITHOUT passing the gate — recorded against your name in the room`)
|
|
1234
|
+
return text(`released ${region}${r && r.gated ? ' (gate passed)' : ''}`)
|
|
1235
|
+
}
|
|
623
1236
|
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
1237
|
case 'hive_edit': {
|
|
625
1238
|
const editPath = String(args.file || '')
|
|
626
1239
|
// Path gate + lease consumed BEFORE the write, not after: a spent lease that still wrote is
|
|
627
1240
|
// indistinguishable from no lease at all.
|
|
628
1241
|
enforceDcoLock({ consumes: true, path: editPath })
|
|
1242
|
+
// HIV-27, same reasoning: this tool's entire promise is that the change reaches every
|
|
1243
|
+
// peer. On a path this client never opens a connection for, it would not -- and it
|
|
1244
|
+
// would still report success.
|
|
1245
|
+
const unshared = requireSession().hive.whyNotShared(editPath)
|
|
1246
|
+
if (unshared) {
|
|
1247
|
+
return err(`refusing to write ${editPath} through the medium — ${unshared}
|
|
1248
|
+
|
|
1249
|
+
Nothing was written. This tool exists to guarantee the change syncs to every peer; on this path it cannot.`)
|
|
1250
|
+
}
|
|
629
1251
|
const r = requireSession().hive.edit(editPath, String(args.content ?? ''), String(args.intent || ''))
|
|
630
1252
|
if (r.error) return err(r.error)
|
|
631
1253
|
return text(r.conflicted
|
|
@@ -649,7 +1271,40 @@ async function dispatch(req) {
|
|
|
649
1271
|
const dropped = (r.coverage || []).filter((c) => !c.covered)
|
|
650
1272
|
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.'}`)
|
|
651
1273
|
}
|
|
652
|
-
case 'hive_members': {
|
|
1274
|
+
case 'hive_members': {
|
|
1275
|
+
const now = Date.now()
|
|
1276
|
+
const s = requireSession()
|
|
1277
|
+
const act = activityMap()
|
|
1278
|
+
const controls = s.hive.doc.getMap('controls')
|
|
1279
|
+
const parkedAfter = ACT.parkedAfterMs()
|
|
1280
|
+
const present = s.hive.members()
|
|
1281
|
+
const presentKeys = new Set(present.map((m) => String(m.name).toLowerCase()))
|
|
1282
|
+
|
|
1283
|
+
const rows = present.map((m) => {
|
|
1284
|
+
const rec = act ? act.get(String(m.name).toLowerCase()) : null
|
|
1285
|
+
const c = controls.get(m.name)
|
|
1286
|
+
const editing = m.editing && now - m.editing.at < 15000 ? `\n editing ${m.editing.file}` : ''
|
|
1287
|
+
return ACT.line(`${m.name} (${m.kind})`, rec, {
|
|
1288
|
+
present: true, passive: !!m.passive,
|
|
1289
|
+
paused: !!(c && c.state === 'paused'),
|
|
1290
|
+
// HIV-P11c. Without this the room said `Ganesh (human) — PARKED: ... anything you
|
|
1291
|
+
// send it now will sit unread until a human prompts it` about the human.
|
|
1292
|
+
human: String(m.kind || '').toLowerCase() === 'human',
|
|
1293
|
+
now, parkedAfter,
|
|
1294
|
+
}) + editing
|
|
1295
|
+
})
|
|
1296
|
+
|
|
1297
|
+
// HIV-P11. THE ONES THAT ARE NOT HERE ANY MORE, which is the half a human coming back in
|
|
1298
|
+
// the morning actually needs. Awareness is ephemeral: an agent that died at 23:00 simply
|
|
1299
|
+
// vanishes from members(), so the old output could not distinguish "never joined" from
|
|
1300
|
+
// "worked here for three hours and stopped". The activity record is durable, so it can.
|
|
1301
|
+
const goneRows = ACT.all(act)
|
|
1302
|
+
.filter((r) => !presentKeys.has(r.key))
|
|
1303
|
+
.map((r) => ACT.line(r.name || r.key, r, { present: false, now, parkedAfter }))
|
|
1304
|
+
|
|
1305
|
+
const body = [...rows, ...(goneRows.length ? ['', '— not connected —', ...goneRows] : [])]
|
|
1306
|
+
return text(body.join('\n') || '(none)')
|
|
1307
|
+
}
|
|
653
1308
|
case 'hive_status': {
|
|
654
1309
|
const s = requireSession()
|
|
655
1310
|
// Connection state belongs here too, not only in hive_wait: this is the tool an
|
|
@@ -657,17 +1312,36 @@ async function dispatch(req) {
|
|
|
657
1312
|
// is almost always "you are still connected, do nothing".
|
|
658
1313
|
let conn = null
|
|
659
1314
|
try { conn = s.hive.status() } catch { }
|
|
1315
|
+
// `privacy` sits next to `connected` on purpose. These are the two questions an
|
|
1316
|
+
// agent asks here, and only one of them had an answer: "am I reaching anyone" was
|
|
1317
|
+
// reportable and "where is my source going" was not. A bare relay URL is not an
|
|
1318
|
+
// answer to the second -- the public relay and a company's own are the same shape.
|
|
1319
|
+
const pv = checkRelay({ relay: s.relay, env: process.env, dir: s.dir, fs })
|
|
660
1320
|
return text({
|
|
661
1321
|
room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION,
|
|
1322
|
+
privacy: pv.headline,
|
|
1323
|
+
...(pv.warn ? { privacyWarning: pv.warn } : {}),
|
|
1324
|
+
...(pv.pin && pv.pin.pinned ? { relayPin: pv.pin.origins.join(', ') } : {}),
|
|
662
1325
|
unread: mailbox().peek(chatArray()).unreadCount,
|
|
663
1326
|
connected: conn ? conn.connected : null,
|
|
664
1327
|
filesSynced: conn ? `${conn.filesSynced}/${conn.files}` : null,
|
|
665
1328
|
})
|
|
666
1329
|
}
|
|
667
|
-
|
|
1330
|
+
// HIV-P11. A deliberate leave is the one departure the room KNOWS about, so the activity
|
|
1331
|
+
// row is dropped rather than left to read as GONE. GONE means "stopped without saying so",
|
|
1332
|
+
// and diluting it with agents that signed off correctly is how a signal stops being read.
|
|
1333
|
+
case 'hive_leave': { try { ACT.forget(activityMap(), requireSession().name) } catch { }; requireSession().hive.stop(); session = null; try { fs.rmSync(SESSION_FILE) } catch {}; return text('left the room') }
|
|
668
1334
|
default: return err(`unknown tool: ${name}`)
|
|
669
1335
|
}
|
|
670
1336
|
} catch (e) { return err(`error: ${e.message}`) }
|
|
1337
|
+
finally {
|
|
1338
|
+
// hive_join only: the session does not exist until the case has run, so this is the single
|
|
1339
|
+
// tool that cannot be stamped on the way in. Everything else is stamped above, before the
|
|
1340
|
+
// switch, so that an agent's own record reflects the call it is currently making.
|
|
1341
|
+
try {
|
|
1342
|
+
if (session && name === 'hive_join') ACT.noteAction(activityMap(), { name: session.name, tool: name })
|
|
1343
|
+
} catch { /* a liveness stamp must never be able to fail a tool call */ }
|
|
1344
|
+
}
|
|
671
1345
|
}
|
|
672
1346
|
|
|
673
1347
|
// MAIL PIGGYBACK — the delivery channel that cannot be ignored.
|
|
@@ -741,7 +1415,44 @@ function mailBanner() {
|
|
|
741
1415
|
} catch { return '' }
|
|
742
1416
|
}
|
|
743
1417
|
|
|
1418
|
+
// A MISSING REQUIRED ARGUMENT MUST BE REFUSED, NOT WRITTEN AS `undefined`.
|
|
1419
|
+
//
|
|
1420
|
+
// Nothing was validating `inputSchema.required`. The SDK does not enforce it, and every handler
|
|
1421
|
+
// read its arguments straight off the object, so a near-miss parameter name sailed through as
|
|
1422
|
+
// `undefined` and the tool reported success. Two real cases, both on 18 Sep 2026:
|
|
1423
|
+
// hive_say({ message }) -> posted an EMPTY message and answered "sent: "
|
|
1424
|
+
// hive_set_state({ value }) -> answered "Set global state [gate:cmd] to [undefined]", which
|
|
1425
|
+
// overwrote the room's gate command with the string "undefined"
|
|
1426
|
+
// and would have broken the handoff gate for every agent.
|
|
1427
|
+
// The failure mode is what makes it serious: the agent is told the action happened. It moves on,
|
|
1428
|
+
// and the room is left holding a corrupted value nobody wrote on purpose. Near-miss names are
|
|
1429
|
+
// the common case, so the refusal names the unknown argument that was supplied too.
|
|
1430
|
+
function missingRequired(req) {
|
|
1431
|
+
const name = req && req.params && req.params.name
|
|
1432
|
+
const tool = TOOLS.find((t) => t.name === name)
|
|
1433
|
+
const schema = tool && tool.inputSchema
|
|
1434
|
+
const required = schema && Array.isArray(schema.required) ? schema.required : []
|
|
1435
|
+
if (!required.length) return ''
|
|
1436
|
+
const args = (req.params && req.params.arguments) || {}
|
|
1437
|
+
// undefined/null only: '' and 0 and false are legitimate values a caller may mean.
|
|
1438
|
+
const missing = required.filter((k) => args[k] === undefined || args[k] === null)
|
|
1439
|
+
if (!missing.length) return ''
|
|
1440
|
+
const known = Object.keys(schema.properties || {})
|
|
1441
|
+
const unknown = Object.keys(args).filter((k) => !known.includes(k))
|
|
1442
|
+
const lines = [
|
|
1443
|
+
`REFUSED -- ${name} needs ${missing.map((m) => `\`${m}\``).join(', ')} and it was not supplied.`,
|
|
1444
|
+
`Nothing was written. This call did NOT happen.`,
|
|
1445
|
+
]
|
|
1446
|
+
if (unknown.length) {
|
|
1447
|
+
lines.push(`You passed ${unknown.map((u) => `\`${u}\``).join(', ')}, which ${name} does not take -- most likely you meant ${missing.map((m) => `\`${m}\``).join(', ')}.`)
|
|
1448
|
+
}
|
|
1449
|
+
lines.push(`${name} accepts: ${known.map((k) => (required.includes(k) ? `${k} (required)` : k)).join(', ')}.`)
|
|
1450
|
+
return lines.join('\n')
|
|
1451
|
+
}
|
|
1452
|
+
|
|
744
1453
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
1454
|
+
const bad = missingRequired(req)
|
|
1455
|
+
if (bad) return { content: [{ type: 'text', text: bad }], isError: true }
|
|
745
1456
|
const res = await dispatch(req)
|
|
746
1457
|
// Never let the banner break a tool: append only to a well-formed text result, and skip the two
|
|
747
1458
|
// tools that already deliver mail themselves (appending would double-report the same messages).
|
|
@@ -771,6 +1482,10 @@ if (isEntryPoint) {
|
|
|
771
1482
|
export const _test = {
|
|
772
1483
|
waitForWork,
|
|
773
1484
|
mailbox,
|
|
1485
|
+
// Exposed so the throttle's saving is asserted against the counter itself rather than inferred
|
|
1486
|
+
// from what happened not to appear in a tool result. Counting absences is how you prove a thing
|
|
1487
|
+
// is quiet, not how you prove it is working.
|
|
1488
|
+
throttleStats: () => throttle.stats(),
|
|
774
1489
|
setSession(s) { session = s },
|
|
775
1490
|
getSession() { return session },
|
|
776
1491
|
callTool: (name, args = {}) =>
|