hivecode-mcp 0.11.6 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HIVE_RULES.md +127 -127
- package/README.md +119 -69
- package/core.js +14 -146
- package/docs/MCP.md +19 -11
- package/docs/SELF_HOSTING.md +256 -0
- package/hive-activity.js +310 -0
- package/hive-mcp.js +447 -76
- package/hive-mux-proto.js +58 -0
- package/hive-mux.js +350 -0
- package/hive-pr-staleness.js +189 -0
- package/hive-privacy.js +288 -0
- package/hive-relay-health.js +225 -0
- package/hive-setup.js +445 -0
- package/hive-stdio.js +168 -0
- package/hive-update.js +58 -0
- package/hive-version.js +59 -0
- package/lang-js.js +11 -2
- package/limits.js +49 -1
- package/package.json +29 -7
- package/reconcile.cjs +477 -0
- package/sync.js +613 -43
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
|
|
@@ -20,12 +20,14 @@
|
|
|
20
20
|
// it returns (read chat + board before editing, announce intent, prefer patches).
|
|
21
21
|
|
|
22
22
|
import fs from 'fs'
|
|
23
|
+
import os from 'os'
|
|
23
24
|
import path from 'path'
|
|
24
25
|
import crypto from 'crypto'
|
|
25
26
|
import { fileURLToPath } from 'url'
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
import { ListToolsRequestSchema, CallToolRequestSchema } from '
|
|
27
|
+
// The stdio server is our own, not @modelcontextprotocol/sdk's: the SDK drags express, hono, zod
|
|
28
|
+
// and ~85 more packages into every install for a slice this small. See hive-stdio.js.
|
|
29
|
+
import { Server, StdioServerTransport, ListToolsRequestSchema, CallToolRequestSchema } from './hive-stdio.js'
|
|
30
|
+
import { startUpdateCheck, updateNotice } from './hive-update.js'
|
|
29
31
|
// Substrate defaults for MCP agents: signed provenance + the silent-fork gate are ON
|
|
30
32
|
// unless explicitly disabled. Agents register this server with arbitrary (often empty)
|
|
31
33
|
// env, so relying on per-registration env vars left rooms in mixed signed/unsigned mode —
|
|
@@ -34,14 +36,17 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot
|
|
|
34
36
|
if (!process.env.HIVE_PROVENANCE) process.env.HIVE_PROVENANCE = 'on'
|
|
35
37
|
if (!process.env.HIVE_FORK_GATE) process.env.HIVE_FORK_GATE = 'on'
|
|
36
38
|
import { startSync, parseLink } from './sync.js'
|
|
39
|
+
import { checkRelay, currentRelayFor, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
|
|
37
40
|
import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName } from './hive-mailbox.js'
|
|
38
41
|
import { INSTRUCTIONS } from './hive-instructions.js'
|
|
39
42
|
import { makeBusy, isBusy, describeRecipient } from './hive-busy.js'
|
|
43
|
+
import * as ACT from './hive-activity.js' // HIV-P11; key MUST be 'activity'
|
|
44
|
+
import * as RH from './hive-relay-health.js' // HIV-P13; the ONLY producer of the not-syncing diagnosis
|
|
40
45
|
import * as OB from './hive-obligations.js'
|
|
41
46
|
import * as RC from './hive-receipts.js'
|
|
42
47
|
import { makeThrottle, RULES_NUDGE } from './hive-throttle.js'
|
|
43
48
|
|
|
44
|
-
const DEFAULT_RELAY = process.env.HIVE_RELAY ||
|
|
49
|
+
const DEFAULT_RELAY = process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY // written once, in hive-privacy.js
|
|
45
50
|
let session = null // { hive, room, relay, dir, name, mailbox }
|
|
46
51
|
|
|
47
52
|
// ONE THROTTLE PER SERVER PROCESS, which is one agent. It deliberately does NOT live on the
|
|
@@ -60,7 +65,16 @@ const text = (s) => {
|
|
|
60
65
|
}
|
|
61
66
|
const err = (s) => ({ content: [{ type: 'text', text: s }], isError: true })
|
|
62
67
|
|
|
68
|
+
// Every route to a relay passes through here, so a retired public relay is followed to its
|
|
69
|
+
// replacement in one place -- whether it arrived in a pasted link, a .hive.json written before
|
|
70
|
+
// the move, or a HIVE_RELAY our own docs once told people to set. See currentRelayFor().
|
|
63
71
|
function resolveRoom(link, dir) {
|
|
72
|
+
const r = resolveRoomRaw(link, dir)
|
|
73
|
+
const { relay, migratedFrom } = currentRelayFor(r.relay)
|
|
74
|
+
return { ...r, relay, migratedFrom }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveRoomRaw(link, dir) {
|
|
64
78
|
if (link) { const { relay, room, token } = parseLink(link); return { relay: relay || DEFAULT_RELAY, room, token, mode: 'joined (link)' } }
|
|
65
79
|
const cfgPath = path.join(path.resolve(dir), '.hive.json')
|
|
66
80
|
if (fs.existsSync(cfgPath)) {
|
|
@@ -102,7 +116,49 @@ async function tryResumeSession() {
|
|
|
102
116
|
} catch { /* resume is best-effort; the agent can always hive_join explicitly */ }
|
|
103
117
|
}
|
|
104
118
|
|
|
105
|
-
|
|
119
|
+
// WHERE AN AGENT'S FILES GO WHEN NOBODY SAID. This was './workspace', resolved against the
|
|
120
|
+
// directory the agent app happened to start this server in: the user's project for Claude Code,
|
|
121
|
+
// something unwritable or unguessable for others -- and the SAME folder for two agents started
|
|
122
|
+
// alike, which is the one thing a room must never have (one folder per participant). Now it is
|
|
123
|
+
// ~/hivecode/<room>/<name>: absolute, writable, and different for every name.
|
|
124
|
+
//
|
|
125
|
+
// One exception keeps returning users whole: an agent that joined the old way and now calls
|
|
126
|
+
// hive_join with nothing, expecting its room back from ./workspace/.hive.json, still gets it.
|
|
127
|
+
// Sending it to a new folder instead would host a brand-new room and report success -- the agent
|
|
128
|
+
// would be alone, told it was joined.
|
|
129
|
+
const HIVE_HOME = process.env.HIVE_HOME || path.join(os.homedir(), 'hivecode')
|
|
130
|
+
const LEGACY_DIR = './workspace'
|
|
131
|
+
function safeSegment(s) {
|
|
132
|
+
const t = String(s || '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[.]+/, '_').slice(0, 64) || '_'
|
|
133
|
+
return /^(con|prn|aux|nul|com\d|lpt\d)$/i.test(t) ? '_' + t : t
|
|
134
|
+
}
|
|
135
|
+
function roomIn(dir) {
|
|
136
|
+
try { return JSON.parse(fs.readFileSync(path.join(path.resolve(dir), '.hive.json'), 'utf8')).room || null } catch { return null }
|
|
137
|
+
}
|
|
138
|
+
function defaultJoinTarget(link, name) {
|
|
139
|
+
// The folders this agent already has a room in: the one its last session used (hive_leave
|
|
140
|
+
// deletes that record, so it only survives a disconnect or a restart), then the old default.
|
|
141
|
+
const prev = readSessionFile()
|
|
142
|
+
const prevDir = prev && prev.dir && roomIn(prev.dir) ? prev.dir : null
|
|
143
|
+
const legacy = roomIn(LEGACY_DIR) ? LEGACY_DIR : null
|
|
144
|
+
if (!link) {
|
|
145
|
+
// "No link, no folder" has always meant "my room, if I have one". Hosting a new one instead
|
|
146
|
+
// would leave an agent that meant to rejoin alone in an empty room, told it had joined.
|
|
147
|
+
const back = prevDir || legacy
|
|
148
|
+
if (back) return { dir: back, link, hostedHere: false }
|
|
149
|
+
const room = 'room-' + crypto.randomBytes(13).toString('base64url')
|
|
150
|
+
return { dir: path.join(HIVE_HOME, room, safeSegment(name)), link: `${DEFAULT_RELAY}|${room}`, hostedHere: true }
|
|
151
|
+
}
|
|
152
|
+
const room = parseLink(link).room
|
|
153
|
+
if (!room) return { dir: LEGACY_DIR, link, hostedHere: false }
|
|
154
|
+
// Rejoining a room this agent was already in lands in the folder it used, files and all. The
|
|
155
|
+
// session record must be under THIS name: two runtimes started in one directory share it.
|
|
156
|
+
if (prevDir && prev.name === name && roomIn(prevDir) === room) return { dir: prevDir, link, hostedHere: false }
|
|
157
|
+
if (legacy && roomIn(legacy) === room) return { dir: legacy, link, hostedHere: false }
|
|
158
|
+
return { dir: path.join(HIVE_HOME, safeSegment(room), safeSegment(name)), link, hostedHere: false }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false }) {
|
|
106
162
|
// REFUSED BEFORE THE OLD SESSION IS TORN DOWN. joinRoom's first act is to stop whatever session
|
|
107
163
|
// exists, so validating after that point would leave an agent with a bad name holding nothing at
|
|
108
164
|
// all -- it would lose a working room to a refusal it could have been given for free.
|
|
@@ -115,14 +171,41 @@ Nothing was joined and your current session is untouched. Re-run hive_join with
|
|
|
115
171
|
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
172
|
}
|
|
117
173
|
if (session) { try { session.hive.stop() } catch {} ; session = null }
|
|
174
|
+
let hostedHere = false
|
|
175
|
+
if (!dir) ({ dir, link, hostedHere } = defaultJoinTarget(link, name))
|
|
118
176
|
const ROOT = path.resolve(dir)
|
|
119
177
|
fs.mkdirSync(ROOT, { recursive: true })
|
|
120
|
-
const { relay, room, token: linkToken, mode } = resolveRoom(link, dir)
|
|
178
|
+
const { relay, room, token: linkToken, mode: resolvedMode, migratedFrom } = resolveRoom(link, dir)
|
|
179
|
+
const mode = hostedHere ? 'HOSTED (new room)' : resolvedMode
|
|
180
|
+
// THE PRIVACY GATE, and note where it sits: after the relay is resolved, BEFORE the
|
|
181
|
+
// .hive.json write below and before startSync opens a socket. A refused join must leave
|
|
182
|
+
// nothing behind -- no config pointing at the wrong relay for the next process to find,
|
|
183
|
+
// and above all no published file. Checking after connecting would report a leak instead
|
|
184
|
+
// of preventing one, which is the difference between a guard and a log line.
|
|
185
|
+
const priv = checkRelay({ relay, env: process.env, dir: ROOT, fs })
|
|
186
|
+
if (!priv.ok) {
|
|
187
|
+
return `${priv.headline}
|
|
188
|
+
|
|
189
|
+
${priv.detail}
|
|
190
|
+
|
|
191
|
+
room: ${room}
|
|
192
|
+
mode: ${mode}
|
|
193
|
+
folder: ${ROOT}
|
|
194
|
+
|
|
195
|
+
NOTHING WAS JOINED. No socket was opened, no file was published, and ${path.join(ROOT, '.hive.json')}
|
|
196
|
+
was not written. Fix the relay and call hive_join again.`
|
|
197
|
+
}
|
|
121
198
|
// token precedence: explicit arg > token baked into the join link > $HIVE_TOKEN.
|
|
122
199
|
const useToken = token || linkToken || process.env.HIVE_TOKEN || ''
|
|
123
200
|
// persist the token too, so a re-join from .hive.json keeps the grant (the
|
|
124
201
|
// invitee pastes the link once; subsequent joins just read the folder config).
|
|
125
|
-
|
|
202
|
+
// pinnedRelay SURVIVES the rewrite. This line used to write only relay/room/token, so a pin
|
|
203
|
+
// kept in .hive.json -- one of the two places readPin() looks -- was erased by the very first
|
|
204
|
+
// join that honoured it. Only the pin is carried over: an old token must not follow the
|
|
205
|
+
// folder into a different room.
|
|
206
|
+
let keep = {}
|
|
207
|
+
try { const old = JSON.parse(fs.readFileSync(path.join(ROOT, '.hive.json'), 'utf8')); if (old && old.pinnedRelay) keep = { pinnedRelay: old.pinnedRelay } } catch { /* none yet */ }
|
|
208
|
+
fs.writeFileSync(path.join(ROOT, '.hive.json'), JSON.stringify({ relay, room, ...(useToken ? { token: useToken } : {}), ...keep }, null, 2))
|
|
126
209
|
// The ACCOUNT licence is passed through but NOT persisted to .hive.json above, and that
|
|
127
210
|
// asymmetry is deliberate. `.hive.json` sits in the project folder, so it gets committed
|
|
128
211
|
// and it gets shared -- fine for a room invite, which is what it is for, and wrong for a
|
|
@@ -133,7 +216,7 @@ This is refused rather than fixed for you because renaming silently is worse: if
|
|
|
133
216
|
// folder is precisely the situation the lock exists to prevent.
|
|
134
217
|
let hive
|
|
135
218
|
try {
|
|
136
|
-
hive = startSync({ relay, room, dir, name, kind: 'ai', owner, token: useToken, log: () => {} })
|
|
219
|
+
hive = startSync({ relay, room, dir, name, kind: kind || 'ai', owner, token: useToken, log: () => {}, passive: !!passive })
|
|
137
220
|
} catch (e) {
|
|
138
221
|
if (e && e.code === 'EHIVELOCKED') {
|
|
139
222
|
return text(
|
|
@@ -143,7 +226,7 @@ This is refused rather than fixed for you because renaming silently is worse: if
|
|
|
143
226
|
}
|
|
144
227
|
throw e
|
|
145
228
|
}
|
|
146
|
-
session = { hive, room, relay, dir, name, owner, mailbox: null }
|
|
229
|
+
session = { hive, room, relay, dir, name, owner, kind: kind || 'ai', passive: !!passive, mailbox: null }
|
|
147
230
|
persistSession({ dir, name, owner }) // survive MCP host restarts (see tryResumeSession)
|
|
148
231
|
// Wait for the first sync so members/board/rules are ready.
|
|
149
232
|
//
|
|
@@ -156,6 +239,7 @@ This is refused rather than fixed for you because renaming silently is worse: if
|
|
|
156
239
|
// here, at the moment we know whether the socket came up.
|
|
157
240
|
let synced = false
|
|
158
241
|
let connErr = ''
|
|
242
|
+
let refusedCode = null
|
|
159
243
|
await new Promise((resolve) => {
|
|
160
244
|
let done = false
|
|
161
245
|
const finish = () => { if (!done) { done = true; resolve() } }
|
|
@@ -163,13 +247,35 @@ This is refused rather than fixed for you because renaming silently is worse: if
|
|
|
163
247
|
// A refusal arrives as a failed upgrade: the relay writes an HTTP status line carrying the
|
|
164
248
|
// Q-code and destroys the socket, so this event is the only place the reason is visible.
|
|
165
249
|
hive.provider.on('connection-error', (ev) => {
|
|
166
|
-
const code = ev && (ev.code || ev.status || ev.type)
|
|
250
|
+
const code = RH.statusFromError(ev) || (ev && (ev.code || ev.status || ev.type))
|
|
251
|
+
if (code && typeof code === 'number') refusedCode = code
|
|
167
252
|
if (!connErr) connErr = `the relay refused the connection${code ? ` (${code})` : ''}`
|
|
168
253
|
})
|
|
169
254
|
setTimeout(finish, 8000) // don't hang if the relay is cold
|
|
170
255
|
})
|
|
171
256
|
if (!synced) {
|
|
257
|
+
// HIV-P12. EVIDENCE BEFORE INFERENCE.
|
|
258
|
+
// If the relay actually refused us with an HTTP status, explainStatus() maps it to the
|
|
259
|
+
// authoritative cause (Q430 -> capacity, Q431 -> HIVE_SEATS, 401/403 -> token).
|
|
260
|
+
// Only when nothing refused us with a code do we query /health to infer whether it was capacity.
|
|
261
|
+
if (refusedCode) {
|
|
262
|
+
const explanation = RH.explainStatus(refusedCode, relay)
|
|
263
|
+
return [
|
|
264
|
+
`NOT JOINED -- the relay refused the connection (${refusedCode}): ${explanation}`,
|
|
265
|
+
`room: ${room}`,
|
|
266
|
+
`relay: ${relay}`,
|
|
267
|
+
`folder: ${ROOT}`,
|
|
268
|
+
'',
|
|
269
|
+
'DO NOT START WORK. You are not in this room. Nothing you write will reach anyone, no',
|
|
270
|
+
'claim you make binds anyone else, and the files in this folder are NOT shared. Any work',
|
|
271
|
+
'you do now is invisible to the team and can be silently overwritten later.',
|
|
272
|
+
'',
|
|
273
|
+
'Tell whoever is running the relay, then retry hive_join. Call hive_status to re-check:',
|
|
274
|
+
'it reports `connected` and `filesSynced`, and both are authoritative.',
|
|
275
|
+
].join('\n')
|
|
276
|
+
}
|
|
172
277
|
const why = connErr || 'no sync within 8s -- the relay did not answer'
|
|
278
|
+
const diag = await RH.explainNotSyncing(relay, { what: 'join' })
|
|
173
279
|
return [
|
|
174
280
|
`NOT JOINED -- ${why}.`,
|
|
175
281
|
`room: ${room}`,
|
|
@@ -180,10 +286,9 @@ This is refused rather than fixed for you because renaming silently is worse: if
|
|
|
180
286
|
'claim you make binds anyone else, and the files in this folder are NOT shared. Any work',
|
|
181
287
|
'you do now is invisible to the team and can be silently overwritten later.',
|
|
182
288
|
'',
|
|
183
|
-
|
|
184
|
-
'
|
|
185
|
-
'
|
|
186
|
-
' limits.maxConnsTotal. Raise HIVE_MAX_CONNS_TOTAL, or narrow what syncs via .gitignore.',
|
|
289
|
+
diag.text,
|
|
290
|
+
'',
|
|
291
|
+
'Two causes the relay cannot tell you about, worth checking if the above says capacity is fine:',
|
|
187
292
|
' - the relay is not running, or the port/room in the link is wrong.',
|
|
188
293
|
' - the room is secured and this link carries no token (Q4xx on connect).',
|
|
189
294
|
'',
|
|
@@ -283,7 +388,18 @@ This is refused rather than fixed for you because renaming silently is worse: if
|
|
|
283
388
|
// The invite link carries the token when there is one — without it a secured room rejects
|
|
284
389
|
// whoever you hand it to, and the Control Room says the link is missing its token.
|
|
285
390
|
const inviteLink = `${relay}|${room}${useToken ? `|${useToken}` : ''}`
|
|
286
|
-
|
|
391
|
+
// THE VERDICT, NOT JUST THE URL. `relay: wss://...` on its own is unreadable to anybody
|
|
392
|
+
// who has not run a relay -- the public one and a company's own look identical. The
|
|
393
|
+
// bracketed verdict is what makes "did my source just leave the building?" answerable
|
|
394
|
+
// without asking, and the warning line below is what makes it actionable.
|
|
395
|
+
const privNote = priv.warn ? `\n\n!! ${priv.warn}` : ''
|
|
396
|
+
// Said out loud, because changing where someone's files go without telling them is the thing
|
|
397
|
+
// this whole module exists to prevent -- even when the move is to where they meant to be.
|
|
398
|
+
const movedNote = migratedFrom
|
|
399
|
+
? `\n\nMOVED: this folder pointed at ${migratedFrom}, a Hivecode relay that has been retired. ` +
|
|
400
|
+
`It now uses ${relay}, and .hive.json has been updated, so this happens once.`
|
|
401
|
+
: ''
|
|
402
|
+
return `${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION}\nothers here: ${others.join(', ') || 'none yet'}\ninvite link: ${inviteLink}${movedNote}${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.${updateNotice() ? '\n' + updateNotice() : ''}\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
|
|
287
403
|
}
|
|
288
404
|
|
|
289
405
|
function requireSession() { if (!session) throw new Error('not in a room — call hive_join first'); return session }
|
|
@@ -298,6 +414,10 @@ function mailbox() {
|
|
|
298
414
|
return s.mailbox
|
|
299
415
|
}
|
|
300
416
|
function chatArray() { return requireSession().hive.doc.getArray('chat').toArray() }
|
|
417
|
+
|
|
418
|
+
// HIV-P11. Returns null rather than throwing when there is no session: a liveness stamp is an
|
|
419
|
+
// observation about a tool call, not part of it, and it must never be the reason a tool fails.
|
|
420
|
+
function activityMap() { try { return requireSession().hive.doc.getMap('activity') } catch { return null } }
|
|
301
421
|
// Names are matched case-insensitively everywhere else (mentionsName is /i), so a busy lookup
|
|
302
422
|
// keyed on the exact string would silently miss whenever a sender typed @sam for Sam.
|
|
303
423
|
function findBusy(map, name) {
|
|
@@ -421,7 +541,7 @@ function waitForWork(timeoutMs, opts = {}) {
|
|
|
421
541
|
|
|
422
542
|
const TOOLS = [
|
|
423
543
|
{ 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.',
|
|
424
|
-
inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'optional join link "wss://relay|room"' }, dir: { type: 'string', description: '
|
|
544
|
+
inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'optional join link "wss://relay|room"' }, dir: { type: 'string', description: 'folder to sync. Leave it out and Hivecode uses ~/hivecode/<room>/<your name>, a folder of your own. Never share a folder with another participant.' }, 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' } } } },
|
|
425
545
|
{ 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.',
|
|
426
546
|
inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name to assign to' }, text: { type: 'string', description: 'what to do' } }, required: ['to', 'text'] } },
|
|
427
547
|
{ name: 'hive_read_tasks', description: 'Read tasks. Shows tasks directed at YOU (act only on accepted ones) and all pending tasks awaiting approval.',
|
|
@@ -586,13 +706,8 @@ function enforceDcoLock(opts = {}) {
|
|
|
586
706
|
// itself reporting the wrong build. Read the truth instead; package.json always ships in the
|
|
587
707
|
// tarball and sits beside this file in both the repo and the published package.
|
|
588
708
|
// A failed read must never stop the server from starting, so it falls back rather than throwing.
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
const v = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version
|
|
592
|
-
if (typeof v === 'string' && v) return v
|
|
593
|
-
} catch { /* fall through */ }
|
|
594
|
-
return '0.0.0-unknown'
|
|
595
|
-
})()
|
|
709
|
+
import { VERSION } from "./hive-version.js"
|
|
710
|
+
export { VERSION }
|
|
596
711
|
// `instructions` is placed in the agent's SYSTEM PROMPT by the client at connect time, which
|
|
597
712
|
// makes it the only channel that reaches an agent before its first tool call. Everything else we
|
|
598
713
|
// have -- tool descriptions, the HIVE_RULES returned by hive_join -- is read too late or scrolls
|
|
@@ -611,6 +726,21 @@ async function dispatch(req) {
|
|
|
611
726
|
// persisted session exists for this cwd, transparently rejoin before any tool runs —
|
|
612
727
|
// the agent (and its human) never see a "not in a room" flake again.
|
|
613
728
|
if (!session && name !== 'hive_join' && name !== 'hive_leave') await tryResumeSession()
|
|
729
|
+
// HIV-P11. STAMPED BEFORE THE TOOL RUNS, not after it returns.
|
|
730
|
+
//
|
|
731
|
+
// The first version stamped in a `finally` -- which is wrong by exactly one call, and the
|
|
732
|
+
// suite caught it: an agent that called hive_members saw ITSELF reported PARKED, because the
|
|
733
|
+
// record it read still held the timestamp of its previous call. The activity is the call
|
|
734
|
+
// BEGINNING; a tool that takes four minutes is working for all four of them, and a tool that
|
|
735
|
+
// throws was still an agent doing something. Stamping first covers both, and covers every
|
|
736
|
+
// tool anyone adds later, because there is no path into the switch that skips this line.
|
|
737
|
+
//
|
|
738
|
+
// hive_wait stamps its own entry and exit (waitingSince must mean "inside the call RIGHT
|
|
739
|
+
// NOW"). hive_leave drops the row instead of refreshing it. hive_join has no session yet, so
|
|
740
|
+
// it is stamped in the finally below -- the one case where after is the only option.
|
|
741
|
+
if (session && name !== 'hive_wait' && name !== 'hive_leave') {
|
|
742
|
+
try { ACT.noteAction(activityMap(), { name: session.name, tool: name }) } catch { }
|
|
743
|
+
}
|
|
614
744
|
switch (name) {
|
|
615
745
|
case 'hive_join': return text(await joinRoom(args))
|
|
616
746
|
case 'hive_say': {
|
|
@@ -643,58 +773,137 @@ async function dispatch(req) {
|
|
|
643
773
|
// reply: it arrives before the sender starts waiting, and it costs the room no message.
|
|
644
774
|
// This is the whole reason busy is state rather than an auto-reply (see hive-busy.js).
|
|
645
775
|
const s = requireSession()
|
|
646
|
-
const
|
|
776
|
+
const members = s.hive.members()
|
|
777
|
+
const memberMap = new Map(members.map((m) => [String(m.name).toLowerCase(), m]))
|
|
778
|
+
const present = new Set(members.map((m) => String(m.name).toLowerCase()))
|
|
647
779
|
const busyMap = s.hive.doc.getMap('busy')
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
780
|
+
const act = activityMap()
|
|
781
|
+
const controls = s.hive.doc.getMap('controls')
|
|
782
|
+
const parkedAfter = ACT.parkedAfterMs()
|
|
783
|
+
const now = Date.now()
|
|
784
|
+
|
|
785
|
+
const everHere = new Set(present)
|
|
786
|
+
for (const m of s.hive.doc.getArray('chat').toArray()) {
|
|
787
|
+
if (m && m.by) everHere.add(String(m.by).toLowerCase())
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// HIV-P11b. Send-time inspection of recipient state:
|
|
791
|
+
// Warns when addressing PARKED, PAUSED, PASSIVE, or GONE/AWAY agents.
|
|
792
|
+
function describeRecipientLiveness(n) {
|
|
793
|
+
const lc = n.toLowerCase()
|
|
794
|
+
const mem = memberMap.get(lc)
|
|
795
|
+
const isPresent = !!mem
|
|
796
|
+
const rec = act ? act.get(lc) : null
|
|
797
|
+
const c = controls.get(mem ? mem.name : n)
|
|
798
|
+
const isPaused = !!(c && c.state === 'paused')
|
|
799
|
+
const isPassive = !!(mem && mem.passive)
|
|
800
|
+
const d = ACT.describe(rec, {
|
|
801
|
+
present: isPresent,
|
|
802
|
+
paused: isPaused,
|
|
803
|
+
passive: isPassive,
|
|
804
|
+
now,
|
|
805
|
+
parkedAfter,
|
|
806
|
+
})
|
|
807
|
+
const displayName = mem ? mem.name : n
|
|
808
|
+
|
|
809
|
+
if (d.state === ACT.PARKED) {
|
|
810
|
+
const age = d.ageMs == null ? 'its whole session' : `${Math.round(d.ageMs / 60000)} min`
|
|
811
|
+
const tool = d.lastTool ? ` (${d.lastTool})` : ''
|
|
812
|
+
return `⚠ @${displayName} — PARKED: connected, but NOT inside hive_wait and silent for ${age}${tool}. ` +
|
|
813
|
+
`It will not wake on its own — anything you send it now will sit unread until a human prompts it.`
|
|
814
|
+
}
|
|
815
|
+
if (d.state === ACT.PAUSED) {
|
|
816
|
+
return `⚠ @${displayName} — PAUSED by a human. It is obeying a stop, not stalled. Do not reassign its work.`
|
|
817
|
+
}
|
|
818
|
+
if (d.state === ACT.PASSIVE) {
|
|
819
|
+
return `⚠ @${displayName} — PASSIVE LISTENER. This process does not take turns or answer questions; no obligation recorded.`
|
|
820
|
+
}
|
|
821
|
+
if (d.state === ACT.GONE) {
|
|
822
|
+
if (everHere.has(lc)) {
|
|
823
|
+
return `⚠ @${displayName} — AWAY RIGHT NOW. Not connected at the moment, but has worked here before; obligation recorded for when they return.`
|
|
824
|
+
} else {
|
|
825
|
+
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.`
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
// d.state is WORKING or WAITING
|
|
829
|
+
const busy = findBusy(busyMap, n)
|
|
830
|
+
if (busy) {
|
|
831
|
+
return describeRecipient(displayName, busy, true, now)
|
|
832
|
+
}
|
|
833
|
+
return ''
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const candidateNames = named.filter((n) => n.toLowerCase() !== 'all' && n.toLowerCase() !== s.name.toLowerCase())
|
|
837
|
+
const seenCandidates = new Set()
|
|
838
|
+
const uniqueCandidates = []
|
|
839
|
+
for (const n of candidateNames) {
|
|
840
|
+
const lc = n.toLowerCase()
|
|
841
|
+
if (!seenCandidates.has(lc)) {
|
|
842
|
+
seenCandidates.add(lc)
|
|
843
|
+
uniqueCandidates.push(n)
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
const notes = uniqueCandidates.map(describeRecipientLiveness).filter(Boolean)
|
|
652
847
|
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
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
//
|
|
848
|
+
// NEEDS REPLY. A question is recorded, not just spoken. One obligation per agent named.
|
|
849
|
+
// HIV-P6: If the room knows nobody will answer, DO NOT record a debt.
|
|
850
|
+
// 1. Absent names: warned immediately, ZERO obligations opened.
|
|
851
|
+
// 2. Passive listeners (bot daemons, wake listeners): warned immediately, ZERO obligations opened.
|
|
852
|
+
// 3. Active participants: real debt opened, dischargeable ONLY by the asker.
|
|
853
|
+
// 4. @all broadcast: targets only active non-passive AI agents in the room.
|
|
659
854
|
let obStr = ''
|
|
660
855
|
if (args.needsReply) {
|
|
661
856
|
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
857
|
const dueMs = Math.max(1000, (Number(args.replyWithinMinutes) || 10) * 60000)
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
858
|
+
|
|
859
|
+
let activeTargets = []
|
|
860
|
+
let awayTargets = []
|
|
861
|
+
let neverSeenNames = []
|
|
862
|
+
let passiveNames = []
|
|
863
|
+
|
|
864
|
+
if (mentionsAll(body)) {
|
|
865
|
+
activeTargets = members
|
|
866
|
+
.filter((m) => String(m.name).toLowerCase() !== s.name.toLowerCase() && !m.passive && m.kind === 'ai')
|
|
867
|
+
.map((m) => String(m.name))
|
|
868
|
+
} else {
|
|
869
|
+
for (const n of uniqueCandidates) {
|
|
870
|
+
const lc = n.toLowerCase()
|
|
871
|
+
const mem = memberMap.get(lc)
|
|
872
|
+
if (mem) {
|
|
873
|
+
if (mem.passive) {
|
|
874
|
+
passiveNames.push(mem.name || n)
|
|
875
|
+
} else {
|
|
876
|
+
activeTargets.push(mem.name || n)
|
|
877
|
+
}
|
|
878
|
+
} else {
|
|
879
|
+
if (everHere.has(lc)) {
|
|
880
|
+
awayTargets.push(n)
|
|
881
|
+
} else {
|
|
882
|
+
neverSeenNames.push(n)
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
681
886
|
}
|
|
682
|
-
|
|
887
|
+
|
|
888
|
+
const openedActive = activeTargets.map((n) => OB.open(obMap, {
|
|
889
|
+
from: s.name, to: n, text: body, dueMs,
|
|
890
|
+
absent: false,
|
|
891
|
+
neverSeen: false,
|
|
892
|
+
}))
|
|
893
|
+
|
|
894
|
+
const openedAway = awayTargets.map((n) => OB.open(obMap, {
|
|
683
895
|
from: s.name, to: n, text: body, dueMs,
|
|
684
|
-
absent:
|
|
685
|
-
neverSeen:
|
|
896
|
+
absent: true,
|
|
897
|
+
neverSeen: false,
|
|
686
898
|
}))
|
|
899
|
+
|
|
900
|
+
const opened = [...openedActive, ...openedAway]
|
|
901
|
+
|
|
687
902
|
if (opened.length) {
|
|
688
|
-
obStr
|
|
903
|
+
obStr += `\n\nYOU ARE OWED AN ANSWER — ${opened.map((o) => `${o.id} (${o.to})`).join(', ')}\n` +
|
|
689
904
|
`This stays open until YOU call hive_answered. The agent you asked cannot close it, and ` +
|
|
690
905
|
`a reply does not close it either — you decide whether you were actually answered. If it ` +
|
|
691
906
|
`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
907
|
}
|
|
699
908
|
}
|
|
700
909
|
return text(stamp(`sent to ${to}: ${body}`) + (notes.length ? '\n\n' + notes.join('\n') : '') + obStr)
|
|
@@ -799,7 +1008,21 @@ async function dispatch(req) {
|
|
|
799
1008
|
}
|
|
800
1009
|
case 'hive_wait': {
|
|
801
1010
|
const ms = Math.min(Math.max(Number(args.timeoutSeconds) || 60, 1), 300) * 1000
|
|
802
|
-
|
|
1011
|
+
// HIV-P11. `waitingSince` means "inside this call right now", never "waited at some
|
|
1012
|
+
// point", and the finally is what keeps that true. A wait that returns or throws while
|
|
1013
|
+
// leaving the flag set describes an agent parked in a call it has already left -- and
|
|
1014
|
+
// describes it as WAITING, the single most reassuring state in the taxonomy. stateOf()
|
|
1015
|
+
// gates WAITING on awareness so a process that DIES here cannot fake it, but a live
|
|
1016
|
+
// process that merely threw is still present, so the gate does not cover that and this
|
|
1017
|
+
// does. The name is captured first because the session can be gone by the finally.
|
|
1018
|
+
const waitName = requireSession().name
|
|
1019
|
+
ACT.noteWaitStart(activityMap(), { name: waitName })
|
|
1020
|
+
let r
|
|
1021
|
+
try {
|
|
1022
|
+
r = await waitForWork(ms, { wakeOn: args.wakeOn, until: args.until })
|
|
1023
|
+
} finally {
|
|
1024
|
+
try { ACT.noteWaitEnd(activityMap(), { name: waitName }) } catch { }
|
|
1025
|
+
}
|
|
803
1026
|
const globalState = requireSession().hive.getState()
|
|
804
1027
|
// THE STATE BLOCK WAS RETURNED ON EVERY SINGLE WAIT, unchanged, forever -- an agent
|
|
805
1028
|
// sitting in the night-shift loop was handed the same JSON hundreds of times and paid for
|
|
@@ -918,16 +1141,52 @@ async function dispatch(req) {
|
|
|
918
1141
|
case 'hive_owed': {
|
|
919
1142
|
const s2 = requireSession()
|
|
920
1143
|
const obMap = s2.hive.doc.getMap('obligations')
|
|
1144
|
+
const act = activityMap()
|
|
1145
|
+
const parkedAfter = ACT.parkedAfterMs()
|
|
1146
|
+
const members = s2.hive.members()
|
|
1147
|
+
const memberMap = new Map(members.map((m) => [String(m.name).toLowerCase(), m]))
|
|
1148
|
+
const controls = s2.hive.doc.getMap('controls')
|
|
1149
|
+
const now = Date.now()
|
|
1150
|
+
|
|
1151
|
+
// HIV-P11b. Dynamic reachability annotation for debts:
|
|
1152
|
+
// Distinguishes actionable pending debts from debts owed by PARKED, GONE, or PAUSED agents.
|
|
1153
|
+
function describeDebt(o) {
|
|
1154
|
+
const base = OB.describe(o, () => now)
|
|
1155
|
+
const lc = String(o.to || '').toLowerCase()
|
|
1156
|
+
const mem = memberMap.get(lc)
|
|
1157
|
+
const rec = act ? act.get(lc) : null
|
|
1158
|
+
const c = controls.get(mem ? mem.name : o.to)
|
|
1159
|
+
const d = ACT.describe(rec, {
|
|
1160
|
+
present: !!mem,
|
|
1161
|
+
paused: !!(c && c.state === 'paused'),
|
|
1162
|
+
passive: !!(mem && mem.passive),
|
|
1163
|
+
now,
|
|
1164
|
+
parkedAfter,
|
|
1165
|
+
})
|
|
1166
|
+
if (d.state === ACT.PARKED) {
|
|
1167
|
+
const age = d.ageMs == null ? 'its whole session' : `${Math.round(d.ageMs / 60000)} min`
|
|
1168
|
+
return `${base} [UNREACHABLE — @${mem ? mem.name : o.to} is PARKED: silent for ${age}, will not wake on its own]`
|
|
1169
|
+
}
|
|
1170
|
+
if (d.state === ACT.GONE) {
|
|
1171
|
+
return `${base} [UNREACHABLE — @${o.to} is GONE: not connected]`
|
|
1172
|
+
}
|
|
1173
|
+
if (d.state === ACT.PAUSED) {
|
|
1174
|
+
return `${base} [PAUSED by human — do not reassign]`
|
|
1175
|
+
}
|
|
1176
|
+
return base
|
|
1177
|
+
}
|
|
1178
|
+
|
|
921
1179
|
if (args.all) {
|
|
922
|
-
const
|
|
923
|
-
if (!
|
|
924
|
-
|
|
1180
|
+
const openList = OB.openList(obMap, () => now)
|
|
1181
|
+
if (!openList.length) return text('Nothing outstanding in this room.')
|
|
1182
|
+
const staleCount = openList.filter(OB.isStale).length
|
|
1183
|
+
return text(`${openList.length} open, ${staleCount} stale:\n` + openList.map((o) => ' ' + describeDebt(o)).join('\n'))
|
|
925
1184
|
}
|
|
926
|
-
const mine = OB.owedBy(obMap, s2.name), theirs = OB.owedTo(obMap, s2.name)
|
|
1185
|
+
const mine = OB.owedBy(obMap, s2.name, () => now), theirs = OB.owedTo(obMap, s2.name, () => now)
|
|
927
1186
|
if (!mine.length && !theirs.length) return text('You owe nothing and are owed nothing. (Pass all:true for the whole room.)')
|
|
928
1187
|
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) => ' ' +
|
|
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) => ' ' +
|
|
1188
|
+
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'))
|
|
1189
|
+
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'))
|
|
931
1190
|
return text(parts.join('\n\n'))
|
|
932
1191
|
}
|
|
933
1192
|
case 'hive_set_state': { requireSession().hive.setState(String(args.key), String(args.val)); return text(`Set global state [${args.key}] to [${args.val}]`) }
|
|
@@ -996,7 +1255,39 @@ Nothing was claimed. Put the path inside the room's sync scope, or pick one that
|
|
|
996
1255
|
// tells three simultaneous callers "it's yours" (measured 5/5). An agent then edits
|
|
997
1256
|
// behind a lock it does not hold. This waits for the relay to serialise it.
|
|
998
1257
|
const got = await requireSession().hive.claimSettled(region, String(args.intent || 'edit'))
|
|
999
|
-
if (got)
|
|
1258
|
+
if (got) {
|
|
1259
|
+
// HIV-P18. "It's yours" was all this said, and for a file outside the working set that
|
|
1260
|
+
// was the entire defect: the agent reads its directory, sees nothing, concludes the file
|
|
1261
|
+
// does not exist, and CREATES it -- which is the blind recreate. Measured 21 Sep 2026:
|
|
1262
|
+
// claim returned true, the file doc was never opened, no bytes arrived, and the agent
|
|
1263
|
+
// was told it held something it could not see.
|
|
1264
|
+
//
|
|
1265
|
+
// sync.js now fetches on claim. This reports WHICH of three states the agent is in,
|
|
1266
|
+
// because they demand different next actions and an empty directory cannot tell them
|
|
1267
|
+
// apart. Awaited here rather than in claim(), whose contract is synchronous.
|
|
1268
|
+
let fetched = null
|
|
1269
|
+
try { fetched = await requireSession().hive.fetch(region) } catch { }
|
|
1270
|
+
const head = `claimed ${region} — it's yours.`
|
|
1271
|
+
if (fetched && fetched.notInRoom) {
|
|
1272
|
+
return text(`${head}
|
|
1273
|
+
|
|
1274
|
+
!! ${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.
|
|
1275
|
+
|
|
1276
|
+
Edit it, then call hive_release.`)
|
|
1277
|
+
}
|
|
1278
|
+
if (fetched && fetched.ok === false) {
|
|
1279
|
+
return text(`${head}
|
|
1280
|
+
|
|
1281
|
+
!! ${region} IS IN THE ROOM BUT ITS CONTENT HAS NOT ARRIVED. ${fetched.reason || ''}
|
|
1282
|
+
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).
|
|
1283
|
+
|
|
1284
|
+
Then call hive_release.`)
|
|
1285
|
+
}
|
|
1286
|
+
const note = fetched && fetched.ok && !fetched.alreadyLocal && !fetched.empty
|
|
1287
|
+
? ` Its content was not in this folder and has been fetched (${fetched.bytes} bytes) — read it before editing.`
|
|
1288
|
+
: ''
|
|
1289
|
+
return text(`claimed ${region} — it's yours.${note} Edit it, then call hive_release.`)
|
|
1290
|
+
}
|
|
1000
1291
|
const c = requireSession().hive.senseClaim(region)
|
|
1001
1292
|
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.`)
|
|
1002
1293
|
}
|
|
@@ -1048,7 +1339,40 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
|
|
|
1048
1339
|
const dropped = (r.coverage || []).filter((c) => !c.covered)
|
|
1049
1340
|
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.'}`)
|
|
1050
1341
|
}
|
|
1051
|
-
case 'hive_members': {
|
|
1342
|
+
case 'hive_members': {
|
|
1343
|
+
const now = Date.now()
|
|
1344
|
+
const s = requireSession()
|
|
1345
|
+
const act = activityMap()
|
|
1346
|
+
const controls = s.hive.doc.getMap('controls')
|
|
1347
|
+
const parkedAfter = ACT.parkedAfterMs()
|
|
1348
|
+
const present = s.hive.members()
|
|
1349
|
+
const presentKeys = new Set(present.map((m) => String(m.name).toLowerCase()))
|
|
1350
|
+
|
|
1351
|
+
const rows = present.map((m) => {
|
|
1352
|
+
const rec = act ? act.get(String(m.name).toLowerCase()) : null
|
|
1353
|
+
const c = controls.get(m.name)
|
|
1354
|
+
const editing = m.editing && now - m.editing.at < 15000 ? `\n editing ${m.editing.file}` : ''
|
|
1355
|
+
return ACT.line(`${m.name} (${m.kind})`, rec, {
|
|
1356
|
+
present: true, passive: !!m.passive,
|
|
1357
|
+
paused: !!(c && c.state === 'paused'),
|
|
1358
|
+
// HIV-P11c. Without this the room said `Ganesh (human) — PARKED: ... anything you
|
|
1359
|
+
// send it now will sit unread until a human prompts it` about the human.
|
|
1360
|
+
human: String(m.kind || '').toLowerCase() === 'human',
|
|
1361
|
+
now, parkedAfter,
|
|
1362
|
+
}) + editing
|
|
1363
|
+
})
|
|
1364
|
+
|
|
1365
|
+
// HIV-P11. THE ONES THAT ARE NOT HERE ANY MORE, which is the half a human coming back in
|
|
1366
|
+
// the morning actually needs. Awareness is ephemeral: an agent that died at 23:00 simply
|
|
1367
|
+
// vanishes from members(), so the old output could not distinguish "never joined" from
|
|
1368
|
+
// "worked here for three hours and stopped". The activity record is durable, so it can.
|
|
1369
|
+
const goneRows = ACT.all(act)
|
|
1370
|
+
.filter((r) => !presentKeys.has(r.key))
|
|
1371
|
+
.map((r) => ACT.line(r.name || r.key, r, { present: false, now, parkedAfter }))
|
|
1372
|
+
|
|
1373
|
+
const body = [...rows, ...(goneRows.length ? ['', '— not connected —', ...goneRows] : [])]
|
|
1374
|
+
return text(body.join('\n') || '(none)')
|
|
1375
|
+
}
|
|
1052
1376
|
case 'hive_status': {
|
|
1053
1377
|
const s = requireSession()
|
|
1054
1378
|
// Connection state belongs here too, not only in hive_wait: this is the tool an
|
|
@@ -1056,17 +1380,36 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
|
|
|
1056
1380
|
// is almost always "you are still connected, do nothing".
|
|
1057
1381
|
let conn = null
|
|
1058
1382
|
try { conn = s.hive.status() } catch { }
|
|
1383
|
+
// `privacy` sits next to `connected` on purpose. These are the two questions an
|
|
1384
|
+
// agent asks here, and only one of them had an answer: "am I reaching anyone" was
|
|
1385
|
+
// reportable and "where is my source going" was not. A bare relay URL is not an
|
|
1386
|
+
// answer to the second -- the public relay and a company's own are the same shape.
|
|
1387
|
+
const pv = checkRelay({ relay: s.relay, env: process.env, dir: s.dir, fs })
|
|
1059
1388
|
return text({
|
|
1060
|
-
room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION,
|
|
1389
|
+
room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION, ...(updateNotice() ? { update: updateNotice() } : {}),
|
|
1390
|
+
privacy: pv.headline,
|
|
1391
|
+
...(pv.warn ? { privacyWarning: pv.warn } : {}),
|
|
1392
|
+
...(pv.pin && pv.pin.pinned ? { relayPin: pv.pin.origins.join(', ') } : {}),
|
|
1061
1393
|
unread: mailbox().peek(chatArray()).unreadCount,
|
|
1062
1394
|
connected: conn ? conn.connected : null,
|
|
1063
1395
|
filesSynced: conn ? `${conn.filesSynced}/${conn.files}` : null,
|
|
1064
1396
|
})
|
|
1065
1397
|
}
|
|
1066
|
-
|
|
1398
|
+
// HIV-P11. A deliberate leave is the one departure the room KNOWS about, so the activity
|
|
1399
|
+
// row is dropped rather than left to read as GONE. GONE means "stopped without saying so",
|
|
1400
|
+
// and diluting it with agents that signed off correctly is how a signal stops being read.
|
|
1401
|
+
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') }
|
|
1067
1402
|
default: return err(`unknown tool: ${name}`)
|
|
1068
1403
|
}
|
|
1069
1404
|
} catch (e) { return err(`error: ${e.message}`) }
|
|
1405
|
+
finally {
|
|
1406
|
+
// hive_join only: the session does not exist until the case has run, so this is the single
|
|
1407
|
+
// tool that cannot be stamped on the way in. Everything else is stamped above, before the
|
|
1408
|
+
// switch, so that an agent's own record reflects the call it is currently making.
|
|
1409
|
+
try {
|
|
1410
|
+
if (session && name === 'hive_join') ACT.noteAction(activityMap(), { name: session.name, tool: name })
|
|
1411
|
+
} catch { /* a liveness stamp must never be able to fail a tool call */ }
|
|
1412
|
+
}
|
|
1070
1413
|
}
|
|
1071
1414
|
|
|
1072
1415
|
// MAIL PIGGYBACK — the delivery channel that cannot be ignored.
|
|
@@ -1195,10 +1538,37 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
1195
1538
|
|
|
1196
1539
|
// Only claim stdio when this file IS the program. Importing it (a test, a tool) must not
|
|
1197
1540
|
// hijack the JSON-RPC channel — that is what made the wait/mailbox logic untestable before.
|
|
1198
|
-
|
|
1199
|
-
|
|
1541
|
+
//
|
|
1542
|
+
// COMPARED AS REAL PATHS. On macOS and Linux, npx starts a package's bin through the symlink in
|
|
1543
|
+
// node_modules/.bin. Node resolves that symlink for import.meta.url and NOT for argv[1], so the
|
|
1544
|
+
// plain comparison this used to make was false there: the server never started, the process
|
|
1545
|
+
// exited 0 with no output, and the agent app showed nothing but "failed to start". Reproduced on
|
|
1546
|
+
// 25 Sep 2026 through a Windows junction, which Node treats the same way.
|
|
1547
|
+
function isMainModule() {
|
|
1548
|
+
if (!process.argv[1]) return false
|
|
1549
|
+
const me = fileURLToPath(import.meta.url)
|
|
1550
|
+
try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(me) } catch { return path.resolve(process.argv[1]) === path.resolve(me) }
|
|
1551
|
+
}
|
|
1552
|
+
const isEntryPoint = isMainModule()
|
|
1553
|
+
if (isEntryPoint && process.argv[2] === 'setup') {
|
|
1554
|
+
// `npx -y hivecode-mcp@latest setup` -- configures every agent app on this machine and checks
|
|
1555
|
+
// each one starts. It never opens stdio: nobody is speaking JSON-RPC to a setup run.
|
|
1556
|
+
const { main } = await import('./hive-setup.js')
|
|
1557
|
+
process.exit(await main(process.argv.slice(3)))
|
|
1558
|
+
} else if (isEntryPoint && (process.argv[2] === '--version' || process.argv[2] === '-v')) {
|
|
1559
|
+
console.log(VERSION)
|
|
1560
|
+
process.exit(0)
|
|
1561
|
+
} else if (isEntryPoint) {
|
|
1200
1562
|
const transport = new StdioServerTransport()
|
|
1563
|
+
// STDIN CLOSING IS THE AGENT APP SAYING GOODBYE -- the MCP spec's stdio shutdown. Leave the
|
|
1564
|
+
// room and exit. The SDK kept this process alive on its open sockets instead, holding a seat
|
|
1565
|
+
// and a presence entry for a member that was gone, until something killed it.
|
|
1566
|
+
server.onclose = () => {
|
|
1567
|
+
try { if (session) session.hive.stop() } catch { /* leaving is best-effort; exiting is not */ }
|
|
1568
|
+
setTimeout(() => process.exit(0), 500)
|
|
1569
|
+
}
|
|
1201
1570
|
await server.connect(transport)
|
|
1571
|
+
startUpdateCheck() // background, short timeout; hive_join and hive_status show what it finds
|
|
1202
1572
|
// stderr is fine for logs; stdout is the JSON-RPC channel and must stay clean.
|
|
1203
1573
|
console.error('[hive-mcp] Hivecode MCP server ready (stdio). Default relay:', DEFAULT_RELAY)
|
|
1204
1574
|
}
|
|
@@ -1206,6 +1576,7 @@ if (isEntryPoint) {
|
|
|
1206
1576
|
/** Test seam: drive the real wait/delivery logic against a doc, with no relay and no stdio. */
|
|
1207
1577
|
export const _test = {
|
|
1208
1578
|
waitForWork,
|
|
1579
|
+
defaultJoinTarget,
|
|
1209
1580
|
mailbox,
|
|
1210
1581
|
// Exposed so the throttle's saving is asserted against the counter itself rather than inferred
|
|
1211
1582
|
// from what happened not to appear in a tool result. Counting absences is how you prove a thing
|