hivecode-mcp 0.12.2 → 0.13.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 +11 -40
- package/LICENSE +21 -21
- package/README.md +421 -167
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -0
- package/docs/GITHUB_WORKFLOW.md +334 -0
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -118
- package/docs/QUICKSTART.md +266 -0
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +450 -0
- package/docs/TROUBLESHOOTING.md +87 -0
- package/hive-activity.js +336 -310
- package/hive-await.js +207 -0
- package/hive-branch.js +686 -0
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +1904 -0
- package/hive-hook-install.js +179 -103
- package/hive-instructions.js +74 -66
- package/hive-listen.js +81 -0
- package/hive-mailbox.js +520 -386
- package/hive-mcp-http.js +580 -0
- package/hive-mcp.js +579 -137
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +350 -350
- package/hive-notify.js +233 -0
- package/hive-obligations.js +247 -247
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +350 -0
- package/hive-privacy.js +327 -288
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +230 -225
- package/hive-room-key.js +209 -0
- package/hive-secrets.js +89 -0
- package/hive-session.js +114 -0
- package/hive-setup.js +558 -445
- package/hive-slack-agent.js +396 -0
- package/hive-slack.js +1809 -0
- package/hive-stdio.js +172 -168
- package/hive-stop-hook.js +293 -241
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -0
- package/hive-update.js +58 -58
- package/hive-version.js +110 -59
- package/hive-webhook.js +54 -0
- package/icr-merge.js +58 -58
- package/icr.js +851 -745
- package/lang-json.js +211 -211
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -394
- package/package.json +189 -164
- package/reconcile.cjs +614 -477
- package/slack-app-manifest-agent.json +61 -0
- package/slack-app-manifest-inbound.json +36 -0
- package/slack-app-manifest.json +27 -0
- package/substrate.js +252 -252
- package/sync.js +1956 -177
- package/grm-link.cjs +0 -141
package/hive-mcp.js
CHANGED
|
@@ -36,15 +36,21 @@ import { startUpdateCheck, updateNotice } from './hive-update.js'
|
|
|
36
36
|
if (!process.env.HIVE_PROVENANCE) process.env.HIVE_PROVENANCE = 'on'
|
|
37
37
|
if (!process.env.HIVE_FORK_GATE) process.env.HIVE_FORK_GATE = 'on'
|
|
38
38
|
import { startSync, parseLink } from './sync.js'
|
|
39
|
-
import { checkRelay, currentRelayFor, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
39
|
+
import { checkRelay, currentRelayFor, controlUrl, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
|
|
40
|
+
import { newPersonalRoom, saveRoomKey, loadRoomKey, hostToken, main as roomKeyMain } from './hive-room-key.js'
|
|
41
|
+
import { isSecuredRoom } from './token.js'
|
|
42
|
+
import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName, collapseRepeats, findClosestKnown, isDirectAddress } from './hive-mailbox.js'
|
|
43
|
+
import { INSTRUCTIONS, JOIN_RULES } from './hive-instructions.js'
|
|
42
44
|
import { makeBusy, isBusy, describeRecipient } from './hive-busy.js'
|
|
43
45
|
import * as ACT from './hive-activity.js' // HIV-P11; key MUST be 'activity'
|
|
44
46
|
import * as RH from './hive-relay-health.js' // HIV-P13; the ONLY producer of the not-syncing diagnosis
|
|
45
47
|
import * as OB from './hive-obligations.js'
|
|
46
48
|
import * as RC from './hive-receipts.js'
|
|
49
|
+
import { findSecrets, describeSecret } from './hive-secrets.js' // a key never goes into the room's chat or tasks either
|
|
47
50
|
import { makeThrottle, RULES_NUDGE } from './hive-throttle.js'
|
|
51
|
+
import * as HB from './hive-branch.js' // a room bound to a git branch: hive_join { branch }, and the branch/pr CLI
|
|
52
|
+
import { isArmed, armedFile, listenCommand, listenHow, listenOrder, LISTEN_IDLE_MIN, AWAIT_JS } from './hive-listen.js'
|
|
53
|
+
import { SESSION_FILE_NAME, readAgents, writeAgents, ownRecord, excludeFromGit } from './hive-session.js'
|
|
48
54
|
|
|
49
55
|
const DEFAULT_RELAY = process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY // written once, in hive-privacy.js
|
|
50
56
|
let session = null // { hive, room, relay, dir, name, mailbox }
|
|
@@ -83,24 +89,49 @@ function resolveRoomRaw(link, dir) {
|
|
|
83
89
|
return { relay: DEFAULT_RELAY, room: 'room-' + crypto.randomBytes(13).toString('base64url'), token: '', mode: 'HOSTED (new room)' }
|
|
84
90
|
}
|
|
85
91
|
|
|
86
|
-
// Session persistence:
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
92
|
+
// Session persistence: in the folder this server was started in, so a restart of the MCP host
|
|
93
|
+
// rejoins as the same agent instead of silently dropping it from the room. That folder is often
|
|
94
|
+
// shared: two Claude Code sessions in one repo is the obvious way to run a branch room. So the file
|
|
95
|
+
// keeps one record per agent, each with this server's pid, and every read takes only this
|
|
96
|
+
// process's own record (hive-session.js says why it once held one agent, and what that broke).
|
|
97
|
+
const SESSION_FILE = path.join(process.cwd(), SESSION_FILE_NAME)
|
|
98
|
+
const CONV = process.env.CLAUDE_CODE_SESSION_ID || process.env.ANTIGRAVITY_CONVERSATION_ID || ''
|
|
99
|
+
let keptOutOfGit = false
|
|
100
|
+
function persistSession({ dir, name, owner, cursor, authed = false, kind = 'ai', passive = false }) {
|
|
91
101
|
try {
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
|
|
102
|
+
const agents = readAgents(SESSION_FILE)
|
|
103
|
+
const prev = ownRecord(agents, { conv: CONV })
|
|
104
|
+
const chatCursor = cursor === undefined ? (prev && prev.name === name ? prev.chatCursor : undefined) : cursor
|
|
105
|
+
// `authed`: the Stop hook reads this file, and must not order a listener into a room that checks
|
|
106
|
+
// tokens -- the agent's shell does not hold this server's token, so that listener could not join.
|
|
107
|
+
// `kind` and `passive`: a resume rejoins from this file, and without them a human came back as an
|
|
108
|
+
// AI and a passive participant as one the room waits on; the Stop hook reads `passive` too.
|
|
109
|
+
const rec = { dir, name, owner, chatCursor, ...(authed ? { authed: true } : {}),
|
|
110
|
+
...(kind && kind !== 'ai' ? { kind } : {}), ...(passive ? { passive: true } : {}),
|
|
111
|
+
pid: process.pid, ...(CONV ? { conv: CONV } : {}), at: Date.now() }
|
|
112
|
+
for (const [n, s] of Object.entries(agents)) if (n !== name && s.pid === process.pid) delete agents[n] // this server's earlier identity
|
|
113
|
+
agents[name] = rec
|
|
114
|
+
writeAgents(SESSION_FILE, agents, rec)
|
|
115
|
+
// The launch folder is often the developer's own checkout: keep our files out of their `git status`.
|
|
116
|
+
if (!keptOutOfGit) { keptOutOfGit = true; excludeFromGit(path.dirname(SESSION_FILE)) }
|
|
95
117
|
} catch { /* best-effort */ }
|
|
96
118
|
}
|
|
97
119
|
function readSessionFile() {
|
|
98
|
-
try { return
|
|
120
|
+
try { return ownRecord(readAgents(SESSION_FILE), { conv: CONV }) } catch { return null }
|
|
121
|
+
}
|
|
122
|
+
/** Take this agent off the record (it left, or was refused), and only this agent. */
|
|
123
|
+
function forgetSession() {
|
|
124
|
+
try {
|
|
125
|
+
const agents = readAgents(SESSION_FILE)
|
|
126
|
+
const mine = ownRecord(agents, { conv: CONV })
|
|
127
|
+
for (const [n, s] of Object.entries(agents)) if (s.pid === process.pid || s === mine) delete agents[n]
|
|
128
|
+
writeAgents(SESSION_FILE, agents)
|
|
129
|
+
} catch { /* best-effort */ }
|
|
99
130
|
}
|
|
100
131
|
/** Save the read cursor so a host restart does not reopen the blind window it just closed. */
|
|
101
132
|
function persistCursor() {
|
|
102
133
|
if (!session || !session.mailbox) return
|
|
103
|
-
persistSession({ dir: session.dir, name: session.name, owner: session.owner, cursor: session.mailbox.cursor })
|
|
134
|
+
persistSession({ dir: session.dir, name: session.name, owner: session.owner, cursor: session.mailbox.cursor, authed: session.authed, kind: session.kind, passive: session.passive })
|
|
104
135
|
// AND PUBLISH IT INTO THE ROOM. The file above is for resuming THIS session; the room copy is
|
|
105
136
|
// what makes "did they actually read it" answerable by anybody other than me. The cursor only
|
|
106
137
|
// advances inside box.take(), which is the call that hands messages to the agent -- so a cursor
|
|
@@ -111,7 +142,7 @@ function persistCursor() {
|
|
|
111
142
|
async function tryResumeSession() {
|
|
112
143
|
try {
|
|
113
144
|
if (!fs.existsSync(SESSION_FILE)) return
|
|
114
|
-
const s = readSessionFile()
|
|
145
|
+
const s = readSessionFile() // only this process's own record: never an agent still running beside it
|
|
115
146
|
if (s && s.dir && s.name && fs.existsSync(path.join(path.resolve(s.dir), '.hive.json'))) await joinRoom(s)
|
|
116
147
|
} catch { /* resume is best-effort; the agent can always hive_join explicitly */ }
|
|
117
148
|
}
|
|
@@ -158,7 +189,7 @@ function defaultJoinTarget(link, name) {
|
|
|
158
189
|
return { dir: path.join(HIVE_HOME, safeSegment(room), safeSegment(name)), link, hostedHere: false }
|
|
159
190
|
}
|
|
160
191
|
|
|
161
|
-
async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false }) {
|
|
192
|
+
async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false, branch = '', repo = '', private: personal = false }) {
|
|
162
193
|
// REFUSED BEFORE THE OLD SESSION IS TORN DOWN. joinRoom's first act is to stop whatever session
|
|
163
194
|
// exists, so validating after that point would leave an agent with a bad name holding nothing at
|
|
164
195
|
// all -- it would lose a working room to a refusal it could have been given for free.
|
|
@@ -170,19 +201,60 @@ Nothing was joined and your current session is untouched. Re-run hive_join with
|
|
|
170
201
|
|
|
171
202
|
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.`
|
|
172
203
|
}
|
|
204
|
+
// A PERSONAL ROOM IS ALWAYS A NEW ONE (8 Oct 2026, hive-room-key.js). Refused here, before the old
|
|
205
|
+
// session is torn down, for the same reason as the name check above.
|
|
206
|
+
if (personal && (link || branch)) {
|
|
207
|
+
return `private: true hosts a NEW personal room, so it cannot be combined with a ${link ? 'link' : 'branch'}.\n\nNothing was joined and your current session is untouched.`
|
|
208
|
+
}
|
|
209
|
+
if (personal && dir && roomIn(dir)) {
|
|
210
|
+
return `${path.resolve(dir)} already belongs to room ${roomIn(dir)}, and a folder holds one room. Pass a new dir, or leave out private to rejoin that room.\n\nNothing was joined and your current session is untouched.`
|
|
211
|
+
}
|
|
212
|
+
// A BRANCH ROOM (hive-branch.js): the link comes from the repo's git config, and the folder is
|
|
213
|
+
// this participant's own, marked so git refuses to run in it. Resolved and refused BEFORE the old
|
|
214
|
+
// session is torn down, for the same reason as the name check above.
|
|
215
|
+
// SINGLE IDENTITY PER MCP PROCESS (Q637). Some host apps (Antigravity, Cursor, Claude Desktop)
|
|
216
|
+
// run ONE shared MCP server process across all their agent windows and tabs. A second join with a
|
|
217
|
+
// different name while a session is live would silently usurp the first session, breaking chat
|
|
218
|
+
// identity and authorship attribution. Refused FIRST, before anything below touches the disk: the
|
|
219
|
+
// branch path marks the folder as not-a-checkout, and a refusal that has already written there is
|
|
220
|
+
// not "nothing was joined" (antigravity-3, 1 Nov).
|
|
221
|
+
if (session && session.name !== name) {
|
|
222
|
+
return `Q637 This Hivecode server is already in use by '${session.name}' in ${session.room} (${session.dir}). If you are a different agent, you are sharing one MCP server with it. Do not leave or rejoin; tell your person: add a second server entry (for example 'hivecode-2') in your app's MCP config and restart the app (docs/JOIN_WITH_AGENT.md). If you ARE '${session.name}' and mean to switch, join again with the name '${session.name}'.\n\nNothing was joined and your current session is untouched.`
|
|
223
|
+
}
|
|
224
|
+
let branchCtx = null
|
|
225
|
+
if (branch) {
|
|
226
|
+
const untouched = '\n\nNothing was joined and your current session is untouched.'
|
|
227
|
+
if (link) return `Pass branch or link, not both: a branch's room link comes from the repo's git config.${untouched}`
|
|
228
|
+
const bound = HB.resolveForJoin({ branch, repo: repo || process.cwd() })
|
|
229
|
+
if (!bound.ok) return bound.message + untouched
|
|
230
|
+
link = bound.link
|
|
231
|
+
if (!dir) dir = HB.roomFolder(link, name)
|
|
232
|
+
const mark = HB.markNotACheckout(path.resolve(dir))
|
|
233
|
+
if (!mark.ok) return `Q564 ${mark.why}${untouched}`
|
|
234
|
+
branchCtx = bound
|
|
235
|
+
}
|
|
173
236
|
if (session) { try { session.hive.stop() } catch {} ; session = null }
|
|
174
237
|
let hostedHere = false
|
|
238
|
+
// The new personal room exists only in memory until the privacy gate below has passed: a refused
|
|
239
|
+
// join must leave nothing behind, and that includes a key for a room nobody ever entered.
|
|
240
|
+
let fresh = null
|
|
241
|
+
if (personal) {
|
|
242
|
+
fresh = newPersonalRoom()
|
|
243
|
+
link = `${DEFAULT_RELAY}|${fresh.room}`
|
|
244
|
+
if (!dir) dir = path.join(HIVE_HOME, fresh.room, safeSegment(name))
|
|
245
|
+
hostedHere = true
|
|
246
|
+
}
|
|
175
247
|
if (!dir) ({ dir, link, hostedHere } = defaultJoinTarget(link, name))
|
|
176
248
|
const ROOT = path.resolve(dir)
|
|
177
249
|
fs.mkdirSync(ROOT, { recursive: true })
|
|
178
250
|
const { relay, room, token: linkToken, mode: resolvedMode, migratedFrom } = resolveRoom(link, dir)
|
|
179
|
-
const mode = hostedHere ? 'HOSTED (new room)' : resolvedMode
|
|
251
|
+
const mode = hostedHere ? (fresh ? 'HOSTED (new personal room)' : 'HOSTED (new room)') : resolvedMode
|
|
180
252
|
// THE PRIVACY GATE, and note where it sits: after the relay is resolved, BEFORE the
|
|
181
253
|
// .hive.json write below and before startSync opens a socket. A refused join must leave
|
|
182
254
|
// nothing behind -- no config pointing at the wrong relay for the next process to find,
|
|
183
255
|
// and above all no published file. Checking after connecting would report a leak instead
|
|
184
256
|
// 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 })
|
|
257
|
+
const priv = checkRelay({ relay, env: process.env, dir: ROOT, fs, room })
|
|
186
258
|
if (!priv.ok) {
|
|
187
259
|
return `${priv.headline}
|
|
188
260
|
|
|
@@ -195,8 +267,13 @@ folder: ${ROOT}
|
|
|
195
267
|
NOTHING WAS JOINED. No socket was opened, no file was published, and ${path.join(ROOT, '.hive.json')}
|
|
196
268
|
was not written. Fix the relay and call hive_join again.`
|
|
197
269
|
}
|
|
270
|
+
if (fresh) saveRoomKey(fresh, { relay, host: name })
|
|
198
271
|
// token precedence: explicit arg > token baked into the join link > $HIVE_TOKEN.
|
|
199
|
-
|
|
272
|
+
let useToken = token || linkToken || process.env.HIVE_TOKEN || ''
|
|
273
|
+
// A personal room this machine hosts: the host's own token is minted from the key, and renewed
|
|
274
|
+
// before it runs out, so the host can never be locked out of its own room. Anyone else joining
|
|
275
|
+
// here keeps the token it came with.
|
|
276
|
+
if (!token && isSecuredRoom(room)) useToken = hostToken(room, name, useToken, { kind: kind || 'ai', owner })
|
|
200
277
|
// persist the token too, so a re-join from .hive.json keeps the grant (the
|
|
201
278
|
// invitee pastes the link once; subsequent joins just read the folder config).
|
|
202
279
|
// pinnedRelay SURVIVES the rewrite. This line used to write only relay/room/token, so a pin
|
|
@@ -216,7 +293,7 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
216
293
|
// folder is precisely the situation the lock exists to prevent.
|
|
217
294
|
let hive
|
|
218
295
|
try {
|
|
219
|
-
hive = startSync({ relay, room, dir, name, kind: kind || 'ai', owner, token: useToken, log: () => {}, passive: !!passive })
|
|
296
|
+
hive = startSync({ relay, room, dir, name, kind: kind || 'ai', owner, token: useToken, log: () => {}, passive: !!passive, app: clientName() })
|
|
220
297
|
} catch (e) {
|
|
221
298
|
if (e && e.code === 'EHIVELOCKED') {
|
|
222
299
|
return text(
|
|
@@ -226,8 +303,8 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
226
303
|
}
|
|
227
304
|
throw e
|
|
228
305
|
}
|
|
229
|
-
session = { hive, room, relay, dir, name, owner, kind: kind || 'ai', passive: !!passive, mailbox: null }
|
|
230
|
-
persistSession({ dir, name, owner }) // survive MCP host restarts (see tryResumeSession)
|
|
306
|
+
session = { hive, room, relay, dir, name, owner, kind: kind || 'ai', passive: !!passive, authed: !!useToken, mailbox: null }
|
|
307
|
+
persistSession({ dir, name, owner, authed: !!useToken, kind: kind || 'ai', passive: !!passive }) // survive MCP host restarts (see tryResumeSession)
|
|
231
308
|
// Wait for the first sync so members/board/rules are ready.
|
|
232
309
|
//
|
|
233
310
|
// REPORT THE TRUTH ABOUT THIS. Until 18 Sep 2026 the timeout below simply resolved and the
|
|
@@ -259,7 +336,7 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
259
336
|
// authoritative cause (Q430 -> capacity, Q431 -> HIVE_SEATS, 401/403 -> token).
|
|
260
337
|
// Only when nothing refused us with a code do we query /health to infer whether it was capacity.
|
|
261
338
|
if (refusedCode) {
|
|
262
|
-
const explanation = RH.explainStatus(refusedCode, relay)
|
|
339
|
+
const explanation = RH.explainStatus(refusedCode, relay, { hasToken: !!useToken })
|
|
263
340
|
return [
|
|
264
341
|
`NOT JOINED -- the relay refused the connection (${refusedCode}): ${explanation}`,
|
|
265
342
|
`room: ${room}`,
|
|
@@ -296,6 +373,29 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
296
373
|
'it reports `connected` and `filesSynced`, and both are authoritative.',
|
|
297
374
|
].join('\n')
|
|
298
375
|
}
|
|
376
|
+
// ONE NAME, ONE PARTICIPANT (Q581). Everything in a room is keyed by name, so a second agent under
|
|
377
|
+
// a name already here is the same identity to everybody: both are told they hold each other's
|
|
378
|
+
// claims, both read each other's mail, and their writes land on each other's files. Measured
|
|
379
|
+
// 3 Oct 2026 -- see sync.js sameNameElsewhere. Refused here, before this session does anything.
|
|
380
|
+
let dupes = []
|
|
381
|
+
try { dupes = typeof hive.sameNameElsewhere === 'function' ? hive.sameNameElsewhere() : [] } catch { /* an engine that cannot tell */ }
|
|
382
|
+
if (dupes.length) {
|
|
383
|
+
const taken = new Set(hive.members().map((m) => String(m.name).toLowerCase()))
|
|
384
|
+
let n = 2
|
|
385
|
+
while (taken.has(`${name}-${n}`.toLowerCase())) n++
|
|
386
|
+
const d = dupes[0]
|
|
387
|
+
const who = [d.kind, d.owner ? `owner ${d.owner}` : '', d.app].filter(Boolean).join(', ')
|
|
388
|
+
try { hive.stop() } catch { }
|
|
389
|
+
session = null
|
|
390
|
+
forgetSession()
|
|
391
|
+
return [
|
|
392
|
+
`NOT JOINED -- Q581: ${d.name} is already in this room (${who}), and two participants cannot share a name.`,
|
|
393
|
+
'Everything here is keyed by name: you would each be told you hold the other\'s claims, read the',
|
|
394
|
+
'other\'s mail, and show as one member, and your writes would land on each other\'s files.',
|
|
395
|
+
`Join again with a different name, e.g. hive_join { name: "${name}-${n}" } with the same link.`,
|
|
396
|
+
`If that ${d.name} is you a moment ago on this machine, wait 30 seconds for it to drop out, then join again.`,
|
|
397
|
+
].join('\n')
|
|
398
|
+
}
|
|
299
399
|
// The mailbox is created AFTER the first sync, so chat.length is the real room history and
|
|
300
400
|
// not 0 — otherwise a fresh joiner would be handed the entire backlog as unread mail.
|
|
301
401
|
// A resumed session carries its saved cursor forward instead, so anything posted while this
|
|
@@ -306,6 +406,7 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
306
406
|
persistCursor()
|
|
307
407
|
|
|
308
408
|
const others = hive.members().filter((m) => m.name !== name).map((m) => `${m.name}(${m.kind})`)
|
|
409
|
+
const mixedNote = mixedLine(hive.members(), name, VERSION_LABEL)
|
|
309
410
|
|
|
310
411
|
// Event-Driven Wakeups: notify the MCP client the millisecond the DCO state changes
|
|
311
412
|
hive.doc.getMap('swarm_state').observe(() => {
|
|
@@ -348,6 +449,7 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
348
449
|
try {
|
|
349
450
|
const s = session
|
|
350
451
|
if (!s || !s.mailbox) return
|
|
452
|
+
catchUp(s)
|
|
351
453
|
const m = s.mailbox.peek(hive.doc.getArray('chat').toArray())
|
|
352
454
|
// Compare with !== rather than >: after hive_wait consumes the backlog the count drops to
|
|
353
455
|
// 0, and a later single mention must still ring. `>` would go permanently silent.
|
|
@@ -382,12 +484,31 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
382
484
|
// The sync engine writes HIVE_RULES.md into the folder on first sync — return it inline
|
|
383
485
|
// so the agent gets the rules with its join. (Fixes a ReferenceError: `rules` was never
|
|
384
486
|
// defined, which made every hive_join return "error: rules is not defined".)
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
//
|
|
390
|
-
|
|
487
|
+
// THE CORE, NOT THE WHOLE FILE. This reply stays in the agent's transcript for the rest of its
|
|
488
|
+
// session, so every character is paid again on every later turn: pasting all of HIVE_RULES.md made
|
|
489
|
+
// the join ~1,900 tokens (27 Sep 2026, bench/token-cost.mjs). The full text is on disk in the folder.
|
|
490
|
+
const rulesText = JOIN_RULES
|
|
491
|
+
// A TOKEN IS A PERSON (3 Oct 2026). The invite link used to carry the joiner's own token, labelled
|
|
492
|
+
// as the thing to forward, so an agent that shared it handed whoever read it the joiner's identity
|
|
493
|
+
// and access -- in the README's own flow, the host's maintainer token to the outside agent scoped to
|
|
494
|
+
// one folder. What is meant for others never carries a token now; the joiner's own Control Room
|
|
495
|
+
// link, which needs one, says whose it is; and a room that checks tokens says each person needs
|
|
496
|
+
// their own (a secured room rejects a link without one, which is the point).
|
|
497
|
+
const inviteLink = `${relay}|${room}`
|
|
498
|
+
// What a person without an agent clicks. Pasting wss:// into a browser's address bar searches for it.
|
|
499
|
+
const browserUrl = controlUrl(inviteLink)
|
|
500
|
+
const ownControl = useToken ? controlUrl(`${relay}|${room}|${useToken}`) : ''
|
|
501
|
+
// A PERSONAL ROOM (hs_ id) is let into by an invite from its host, and nothing else: the plain
|
|
502
|
+
// link below gets a stranger nothing, so it is not offered as the way in.
|
|
503
|
+
const personalRoom = isSecuredRoom(room)
|
|
504
|
+
const ownControlLine = ownControl ? `\nyour Control Room (carries YOUR token: open it yourself, never share it): ${ownControl}` : ''
|
|
505
|
+
const tokenNote = personalRoom
|
|
506
|
+
? (loadRoomKey(room)
|
|
507
|
+
? `\nPERSONAL ROOM: the relay lets in only people you invite, even on the public relay. It still carries the files, so for code that must never leave your machines, run your own relay. Invite one person at a time (each link works only for them): npx -y hivecode-mcp@latest invite <name> --dir "${ROOT}" (add --reader, or --paths src,docs). Take one back: npx -y hivecode-mcp@latest revoke <name> --dir "${ROOT}"`
|
|
508
|
+
: `\nPERSONAL ROOM: only people its host invites can join. To bring someone in, ask the host for an invite of their own; never pass on yours.`) + ownControlLine
|
|
509
|
+
: useToken
|
|
510
|
+
? `\nthis room checks tokens: each person joins with a token of their own. Mint one per person with npx -y -p hivecode-relay hivecode-token --name <them> --room "${room}" --role agent (add --paths to limit them) and send it to them directly. Never pass on yours: whoever holds it joins as you.` + ownControlLine
|
|
511
|
+
: ''
|
|
391
512
|
// THE VERDICT, NOT JUST THE URL. `relay: wss://...` on its own is unreadable to anybody
|
|
392
513
|
// who has not run a relay -- the public one and a company's own look identical. The
|
|
393
514
|
// bracketed verdict is what makes "did my source just leave the building?" answerable
|
|
@@ -399,9 +520,47 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
399
520
|
? `\n\nMOVED: this folder pointed at ${migratedFrom}, a Hivecode relay that has been retired. ` +
|
|
400
521
|
`It now uses ${relay}, and .hive.json has been updated, so this happens once.`
|
|
401
522
|
: ''
|
|
402
|
-
|
|
523
|
+
// What the room cannot carry (binary, over 1 MB, always-ignored) comes from git, once the room's
|
|
524
|
+
// own files are known. Only those: a text file missing here was deleted in the room.
|
|
525
|
+
let branchNote = ''
|
|
526
|
+
if (branchCtx) {
|
|
527
|
+
let filled = 0
|
|
528
|
+
let fillErr = ''
|
|
529
|
+
if (branchCtx.commit) { try { filled = HB.fillFromGit({ root: branchCtx.root, commit: branchCtx.commit, dir: ROOT }) } catch (e) { fillErr = e.message } }
|
|
530
|
+
branchNote = `BRANCH ROOM: this room is git branch "${branchCtx.branch}" of ${branchCtx.root}.\n` +
|
|
531
|
+
`Edit files in YOUR folder (${ROOT}), never in ${branchCtx.root}. It is deliberately not a git checkout: git refuses to run there, so no git command can rewrite the room's files. Do not commit; the work lands on "${branchCtx.branch}" as one commit per agent when the developer runs: npx -y hivecode-mcp@latest pr ${branchCtx.branch}` +
|
|
532
|
+
(filled ? `\nFilled from git: ${filled} file(s) the room does not carry (binary, over 1 MB, or never synced).` : '') +
|
|
533
|
+
(fillErr ? `\nCould not fill non-text files from git (${fillErr}); binary files may be missing here.` : '') + '\n\n'
|
|
534
|
+
}
|
|
535
|
+
// THE INVITE, READY TO FORWARD (onboarding audit, 2 Oct 2026). The host used to assemble it by
|
|
536
|
+
// hand: the install, the reload step, the sentence, the link. setup given a link prints that
|
|
537
|
+
// person's reload steps and their exact sentence, so one message covers all of it. Host only:
|
|
538
|
+
// this reply stays in the transcript, and a joiner already has the link it came in by.
|
|
539
|
+
// One line per thing a person acts on (the stranger walk, 3 Oct: as one line it pasted into chat
|
|
540
|
+
// apps as a wall of links).
|
|
541
|
+
const people = browserUrl ? `\n No AI agent? Open ${browserUrl}` : ''
|
|
542
|
+
const invite = personalRoom ? ''
|
|
543
|
+
: /^HOSTED/.test(mode)
|
|
544
|
+
? `\nto invite someone, forward this as is:\n Join my Hivecode room. In a terminal, run this once (needs Node.js 18+), then do what it prints:\n npx -y hivecode-mcp@latest setup "${inviteLink}"${people}`
|
|
545
|
+
: (browserUrl ? `\nbrowser (people without an agent): ${browserUrl}` : '')
|
|
546
|
+
// No "announce your version" rule any more: since 29 Sep the room flags a mixed build itself
|
|
547
|
+
// (sync.js checkBuilds), and the joiner is told here, by name, when one is already in the room.
|
|
548
|
+
return `${branchNote}${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION_LABEL}\nothers here: ${others.join(', ') || 'none yet'}${mixedNote}\n${personalRoom ? 'room link (lets nobody in without an invite)' : 'invite link'}: ${inviteLink}${tokenNote}${invite}${movedNote}${privNote}${updateNotice() ? '\n\n' + updateNotice() : ''}\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// A MIXED BUILD, TOLD TO THE ONE WHO CAN ACT ON IT (2 Oct 2026). The room's own warning names
|
|
552
|
+
// nobody, and agents are told to skip messages that name nobody; the joiner can act, so the join
|
|
553
|
+
// reply names every AI member whose reported build differs. Humans carry no engine build, and a
|
|
554
|
+
// client that reports none is the room's warning to give (checkBuilds speaks for old engines).
|
|
555
|
+
function mixedLine(members, me, label) {
|
|
556
|
+
const diff = (members || []).filter((m) => m && m.name && m.name !== me && String(m.kind || '').toLowerCase() !== 'human' && m.hv && m.hv !== label)
|
|
557
|
+
if (!diff.length) return ''
|
|
558
|
+
return `\n⚠ MIXED BUILDS: ${diff.map((m) => `${m.name} runs ${m.hv}`).join(', ')}; you run ${label}. Two builds in one room can lose file content: tell your person, so everyone runs the same build.`
|
|
403
559
|
}
|
|
404
560
|
|
|
561
|
+
// Connected-or-not without status()'s file counts, which stat every file in the room. Falls back
|
|
562
|
+
// to status() for a hive that predates connection() (a test double, an older engine).
|
|
563
|
+
const connectionOf = (hive) => (typeof hive.connection === 'function' ? hive.connection() : hive.status())
|
|
405
564
|
function requireSession() { if (!session) throw new Error('not in a room — call hive_join first'); return session }
|
|
406
565
|
/** The read cursor for this participant. Lazily built if a session predates it. */
|
|
407
566
|
function mailbox() {
|
|
@@ -411,8 +570,24 @@ function mailbox() {
|
|
|
411
570
|
// rather than two that disagree.
|
|
412
571
|
try { throttle.sent('rules', RULES_NUDGE) } catch { }
|
|
413
572
|
if (!s.mailbox) s.mailbox = createMailbox({ me: s.name, startAt: s.hive.doc.getArray('chat').length })
|
|
573
|
+
catchUp(s)
|
|
414
574
|
return s.mailbox
|
|
415
575
|
}
|
|
576
|
+
// THE LISTENER READS MAIL TOO (3 Oct 2026). An agent on Claude Code or Antigravity is woken by
|
|
577
|
+
// hivecode-await, a separate process with a cursor of its own, so every message it delivered was
|
|
578
|
+
// still unread here: the next tool call announced "UNREAD MAIL: N ... Handle before continuing"
|
|
579
|
+
// about mail the agent had already acted on (19 of them in one session), and an agent that obeys
|
|
580
|
+
// redoes old instructions. Both now meet at the room's per-member cursor, which only moves forward:
|
|
581
|
+
// whichever of them handed a message over, the other knows. A cursor past the end of chat belongs to
|
|
582
|
+
// a rebuilt room and is ignored, as the mailbox itself does. Every peek and take goes through here.
|
|
583
|
+
function catchUp(s) {
|
|
584
|
+
if (!s || !s.mailbox) return
|
|
585
|
+
try {
|
|
586
|
+
const c = RC.cursorOf(s.hive.doc.getMap('cursors'), s.name)
|
|
587
|
+
const at = (c && Number(c.at)) || 0
|
|
588
|
+
if (at > s.mailbox.cursor && at <= s.hive.doc.getArray('chat').length) s.mailbox.seek(at)
|
|
589
|
+
} catch { /* a room that predates receipts */ }
|
|
590
|
+
}
|
|
416
591
|
function chatArray() { return requireSession().hive.doc.getArray('chat').toArray() }
|
|
417
592
|
|
|
418
593
|
// HIV-P11. Returns null rather than throwing when there is no session: a liveness stamp is an
|
|
@@ -539,61 +714,116 @@ function waitForWork(timeoutMs, opts = {}) {
|
|
|
539
714
|
})
|
|
540
715
|
}
|
|
541
716
|
|
|
717
|
+
// SAID ONCE, THEN SHORT (27 Sep 2026). An explanation is worth its tokens the first time an agent
|
|
718
|
+
// meets a rule in a session and nothing after that: results stay in the transcript, so a paragraph
|
|
719
|
+
// repeated on every call is paid again on every later turn. The warning itself always stays.
|
|
720
|
+
const explained = new Set()
|
|
721
|
+
const firstTime = (key) => (explained.has(key) ? false : (explained.add(key), true))
|
|
722
|
+
|
|
723
|
+
// A ROOM CHANGE THAT COULD NOT LAND HERE (3 Oct 2026). sync.js tells the room when a file in this
|
|
724
|
+
// folder is read-only or locked and so stays behind the room's copy, but that line goes out in this
|
|
725
|
+
// agent's own name, and an agent is never handed its own lines: the one participant working from
|
|
726
|
+
// the stale copy was the one nobody told. Said once per file, on a call the agent was making anyway.
|
|
727
|
+
function unwritableNotice(s) {
|
|
728
|
+
try {
|
|
729
|
+
const fresh = (s.hive.unwritable ? s.hive.unwritable() : []).filter((u) => firstTime('unwritable:' + u.path))
|
|
730
|
+
if (!fresh.length) return ''
|
|
731
|
+
const shown = fresh.slice(0, 5)
|
|
732
|
+
return `\n\n⚠ ${fresh.length} file(s) in your folder could not be written (read-only, or locked by another program), so your copy is OLDER than the room's:\n` +
|
|
733
|
+
shown.map((u) => ` - ${u.path} (${u.code})`).join('\n') +
|
|
734
|
+
(fresh.length > shown.length ? `\n ... (+${fresh.length - shown.length} more; hive_status lists all)` : '') +
|
|
735
|
+
`\nDo not work from that copy. Make it writable or close what holds it, or tell your person: it is written as soon as it can be, and anything you changed in it is merged, not lost.`
|
|
736
|
+
} catch { return '' }
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// TWO ROOM FILES, ONE FILE ON THIS DISK (3 Oct 2026; sync.js checkCaseCollision). Case.js and case.js
|
|
740
|
+
// can both be in the room, but a Windows or Mac disk holds only one of them, so the other is not in
|
|
741
|
+
// this folder. The room is told in this agent's own name; this is where the agent is told.
|
|
742
|
+
function collidedNotice(s) {
|
|
743
|
+
try {
|
|
744
|
+
const fresh = (s.hive.collided ? s.hive.collided() : []).filter((c) => firstTime('collided:' + c.path))
|
|
745
|
+
if (!fresh.length) return ''
|
|
746
|
+
const shown = fresh.slice(0, 5)
|
|
747
|
+
return `\n\n⚠ ${fresh.length} room file(s) differ from a file in your folder only in upper/lower case. This disk cannot hold both, so they are NOT in your folder:\n` +
|
|
748
|
+
shown.map((c) => ` - ${c.path} (your folder has ${c.with})`).join('\n') +
|
|
749
|
+
(fresh.length > shown.length ? `\n ... (+${fresh.length - shown.length} more; hive_status lists all)` : '') +
|
|
750
|
+
`\nThey are separate files in the room: editing yours never changes the other. To work on one, ask whoever added it to rename it so the two names differ by more than case.`
|
|
751
|
+
} catch { return '' }
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// A KEY IN A FILE KEEPS THE FILE HERE (3 Oct 2026; sync.js heldForSecret). The agent that pasted it
|
|
755
|
+
// is usually the one that needs telling, and the room line about it is in that agent's own name.
|
|
756
|
+
function withheldNotice(s) {
|
|
757
|
+
try {
|
|
758
|
+
const fresh = (s.hive.withheld ? s.hive.withheld() : []).filter((w) => firstTime('withheld:' + w.path))
|
|
759
|
+
if (!fresh.length) return ''
|
|
760
|
+
const shown = fresh.slice(0, 5)
|
|
761
|
+
return `\n\n⛔ ${fresh.length} file(s) in your folder were NOT shared, because each contains a credential (a key or token) the room does not have:\n` +
|
|
762
|
+
shown.map((w) => ` - ${w.path}: ${/^(?:[aeiou]|npm\b)/i.test(w.kind) ? 'an' : 'a'} ${w.kind} on line ${w.line}`).join('\n') +
|
|
763
|
+
(fresh.length > shown.length ? `\n ... (+${fresh.length - shown.length} more; hive_status lists all)` : '') +
|
|
764
|
+
`\nNobody else has your changes to these files. Read the key from the environment instead (put it in .env, which is never shared); each file is shared on its next save without the key.`
|
|
765
|
+
} catch { return '' }
|
|
766
|
+
}
|
|
767
|
+
|
|
542
768
|
const TOOLS = [
|
|
543
|
-
{ name: 'hive_join', description: 'Join
|
|
544
|
-
inputSchema: { type: 'object', properties: { link: { type: 'string', description: '
|
|
545
|
-
{ name: 'hive_assign', description: '
|
|
546
|
-
inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name
|
|
547
|
-
{ name: 'hive_read_tasks', description: '
|
|
769
|
+
{ name: 'hive_join', description: 'Join a Hivecode room, or host a new one, for a folder. Uses the join link if given, else <dir>/.hive.json, else hosts. Returns the room details and the rules you must follow.',
|
|
770
|
+
inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'join link "wss://relay|room"' }, dir: { type: 'string', description: 'folder to sync; default ~/hivecode/<room>/<name>. Never share a folder with another participant.' }, name: { type: 'string', description: 'your display name in the room' }, owner: { type: 'string', description: 'the human who approves tasks aimed at you' }, token: { type: 'string', description: 'token for a secured room (default $HIVE_TOKEN)' }, kind: { type: 'string', description: '"ai" (default) or "human"' }, passive: { type: 'boolean', description: 'true = a listener or bot that never takes turns or answers' }, branch: { type: 'string', description: 'instead of link: the room bound to this git branch (npx -y hivecode-mcp@latest branch <name>)' }, repo: { type: 'string', description: 'the repo for branch (default: your working directory)' }, private: { type: 'boolean', description: 'host a NEW room only people you invite can join' } } } },
|
|
771
|
+
{ name: 'hive_assign', description: 'Give a task to a participant by name. A task for an AI with an owner stays PENDING until that owner approves.',
|
|
772
|
+
inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name' }, text: { type: 'string', description: 'the task' } }, required: ['to', 'text'] } },
|
|
773
|
+
{ name: 'hive_read_tasks', description: 'Tasks aimed at you (act only on accepted ones) and all pending tasks awaiting approval.',
|
|
774
|
+
inputSchema: { type: 'object', properties: {} } },
|
|
775
|
+
{ name: 'hive_wait', description: 'Your idle state. Blocks until there is approved work, mail for you, or a state flag you named reaches its value, then returns all of it; returns at once if any is already true. Loop: hive_wait -> work -> hive_complete -> hive_wait.',
|
|
776
|
+
inputSchema: { type: 'object', properties: { timeoutSeconds: { type: 'number', description: 'max seconds to wait (default 60, 110 in Claude Code and Antigravity; max 300, kept under your app\'s own tool deadline)' }, wakeOn: { type: 'string', enum: ['any', 'mention'], description: '"any" (default), or "mention" = wake only when @mentioned or assigned; other mail still arrives on the next wake' }, until: { type: 'object', description: 'wake when a project state key reaches a value: {key, is}, {key, in: [...]}, {key, not} (unset counts as not), or {key} = set at all. The result carries stateReached.', properties: { key: { type: 'string' }, is: { type: 'string' }, in: { type: 'array', items: { type: 'string' } }, not: { type: 'string' } }, required: ['key'] } } } },
|
|
777
|
+
{ name: 'hive_check', description: 'Non-blocking check for use mid-task: unread mail for you and approved work, returned at once. Call it between edits; if it reports priority "stop", handle that first.',
|
|
778
|
+
inputSchema: { type: 'object', properties: { peek: { type: 'boolean', description: 'true = look without marking messages read' } } } },
|
|
779
|
+
{ name: 'hive_approve', description: 'Approve a pending task for an AI you own; it may then act on it.',
|
|
780
|
+
inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'task id' } }, required: ['id'] } },
|
|
781
|
+
{ name: 'hive_deny', description: 'Deny a pending task for an AI you own; it will not act on it.',
|
|
782
|
+
inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'task id' } }, required: ['id'] } },
|
|
783
|
+
{ name: 'hive_complete', description: 'Mark a task you were doing as done, with an optional note.',
|
|
784
|
+
inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'task id' }, note: { type: 'string', description: 'what was done' } }, required: ['id'] } },
|
|
785
|
+
{ name: 'hive_say', description: 'Post to the room chat. Put @Name on anything meant for someone: a message naming nobody is ambient and gets skipped. Announce a file before you edit it.',
|
|
786
|
+
inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'the message' }, urgent: { type: 'boolean', description: 'true = [!]: the recipient handles it before continuing' }, needsReply: { type: 'boolean', description: 'true = a question: tracked until YOU close it with hive_answered; you are told at once if someone you named is not here' }, replyWithinMinutes: { type: 'number', description: 'remind you after this many minutes (default 10)' } }, required: ['text'] } },
|
|
787
|
+
{ name: 'hive_delivered', description: 'Did recipients actually READ what you said? Per recipient: READ; UNREAD (here, behind); UNKNOWN (no read position, e.g. a human client, so silence means nothing); UNREACHABLE (left, or you are offline). Check before concluding someone is ignoring you.',
|
|
788
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'recent addressed messages to check (default 10)' } } } },
|
|
789
|
+
{ name: 'hive_answered', description: 'Close a question you asked with needsReply once you have what you needed. Only the asker can close it; a reply alone does not.',
|
|
790
|
+
inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'the obligation id' }, from: { type: 'string', description: 'close every question you asked this agent' } } } },
|
|
791
|
+
{ name: 'hive_owed', description: 'Open questions: those aimed at you (answer them) and those you asked (close them with hive_answered). STALE means reminded three times. Read it when the room goes quiet.',
|
|
792
|
+
inputSchema: { type: 'object', properties: { all: { type: 'boolean', description: 'true = the whole room' } } } },
|
|
793
|
+
{ name: 'hive_busy', description: 'Mark yourself mid-task: whoever @mentions you is told to expect a delay. Posts nothing, silences nothing, expires by itself. Clear it when you finish.',
|
|
794
|
+
inputSchema: { type: 'object', properties: { what: { type: 'string', description: 'what you are doing, in a few words' }, minutes: { type: 'number', description: 'how long (default 10)' }, clear: { type: 'boolean', description: 'true = free again' } } } },
|
|
795
|
+
{ name: 'hive_read_chat', description: 'Read recent room chat: what people and agents are doing and saying.',
|
|
796
|
+
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 all history' }, unreadOnly: { type: 'boolean', description: 'true = only what you have not been handed yet (does not mark it read)' }, full: { type: 'boolean', description: 'true = long messages whole (other people\'s are cut at 280 chars by default)' } } } },
|
|
797
|
+
{ name: 'hive_read_board', description: 'Recent whole-file rewrites: who, which file, which symbols. If a file you plan to edit was just rewritten, re-read it first.',
|
|
798
|
+
inputSchema: { type: 'object', properties: {} } },
|
|
799
|
+
{ name: 'hive_claim', description: 'Claim a file (or region) before you edit it. Returns whether you got it; if not, someone holds it, so work on something else. Claims expire; hive_release when done.',
|
|
800
|
+
inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'file path or region id' }, intent: { type: 'string', description: 'what you intend to do' } }, required: ['region'] } },
|
|
801
|
+
{ name: 'hive_release', description: 'Hand a file back. If the room set a gate, it runs in your folder first, and a failing check REFUSES the release: the file stays yours and retrying will not help. Fix what it reported, or force with a reason to abandon the work.',
|
|
802
|
+
inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'file path or region id' }, force: { type: 'boolean', description: 'release despite a failing gate; recorded against you and announced; for abandoned work only' }, why: { type: 'string', description: 'why the work is abandoned (give it whenever force is true)' } }, required: ['region'] } },
|
|
803
|
+
{ name: 'hive_claims', description: 'The live claim board: who holds which file. Authoritative; read it instead of asking the room.',
|
|
548
804
|
inputSchema: { type: 'object', properties: {} } },
|
|
549
|
-
{ name: '
|
|
550
|
-
inputSchema: { type: 'object', properties: {
|
|
551
|
-
{ name: '
|
|
552
|
-
inputSchema: { type: 'object', properties: {
|
|
553
|
-
{ name: '
|
|
554
|
-
inputSchema: { type: 'object', properties: {
|
|
555
|
-
{ name: '
|
|
556
|
-
inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } },
|
|
557
|
-
{ name: 'hive_complete', description: 'Mark a task you were doing as done (optionally with a note).',
|
|
558
|
-
inputSchema: { type: 'object', properties: { id: { type: 'string' }, note: { type: 'string' } }, required: ['id'] } },
|
|
559
|
-
{ 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.',
|
|
560
|
-
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'] } },
|
|
561
|
-
{ 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.',
|
|
562
|
-
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'how many of your recent addressed messages to check (default 10)' } } } },
|
|
563
|
-
{ 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.',
|
|
564
|
-
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' } } } },
|
|
565
|
-
{ 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.',
|
|
566
|
-
inputSchema: { type: 'object', properties: { all: { type: 'boolean', description: 'true = the whole room, not just yours' } } } },
|
|
567
|
-
{ 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.',
|
|
568
|
-
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' } } } },
|
|
569
|
-
{ 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.',
|
|
570
|
-
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)' } } } },
|
|
571
|
-
{ 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.',
|
|
805
|
+
{ name: 'hive_edit', description: 'Write a file through the medium (preferred over editing on disk): your intent and the exact change are signed into the provenance ledger as it lands. Claim the file first.',
|
|
806
|
+
inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'relative path in the room' }, content: { type: 'string', description: 'the complete new file content' }, intent: { type: 'string', description: 'why you are making the change (signed into provenance)' } }, required: ['file', 'content'] } },
|
|
807
|
+
{ name: 'hive_fork', description: 'Read a silent fork on a file: the common base plus every divergent version with its author and signed intent. Reconcile them, then submit with hive_resolve.',
|
|
808
|
+
inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'the forked file path, as announced in chat' } }, required: ['file'] } },
|
|
809
|
+
{ name: 'hive_resolve', description: 'Resolve a fork with the full reconciled file, serving every side\'s intent. It must parse with no dangling references, or it is refused and nothing changes.',
|
|
810
|
+
inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'the forked file path' }, code: { type: 'string', description: 'the complete resolved content, no conflict markers' } }, required: ['file', 'code'] } },
|
|
811
|
+
{ name: 'hive_members', description: 'List who is currently in the room (humans and AI agents).',
|
|
572
812
|
inputSchema: { type: 'object', properties: {} } },
|
|
573
|
-
{ name: '
|
|
574
|
-
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'] } },
|
|
575
|
-
{ 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).',
|
|
576
|
-
inputSchema: { type: 'object', properties: {
|
|
577
|
-
region: { type: 'string', description: 'the file path (or region id) to release' },
|
|
578
|
-
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.' },
|
|
579
|
-
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.' },
|
|
580
|
-
}, required: ['region'] } },
|
|
581
|
-
{ 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.',
|
|
813
|
+
{ name: 'hive_status', description: 'Show the current session (room, relay, folder, name).',
|
|
582
814
|
inputSchema: { type: 'object', properties: {} } },
|
|
583
|
-
{ name: '
|
|
584
|
-
inputSchema: { type: 'object', properties: {
|
|
585
|
-
{ name: '
|
|
586
|
-
inputSchema: { type: 'object', properties: {
|
|
587
|
-
{ name: '
|
|
588
|
-
inputSchema: { type: 'object', properties: {
|
|
589
|
-
{ name: '
|
|
590
|
-
|
|
591
|
-
{ name: '
|
|
592
|
-
|
|
593
|
-
{ name: '
|
|
594
|
-
|
|
595
|
-
{ name: 'hive_attest', description: 'Record what YOUR folder actually holds for a file, so the room can tell whether peers agree. Measure it yourself (bytes and lines) and report. If another peer has attested a DIFFERENT size, everyone is warned immediately — that disagreement is the point. Use it after publishing a file, and when asked to confirm somebody else\'s publish. Two matching attestations from different peers is the only real proof a write landed; your own folder shows your own write whether or not it propagated.', inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'workspace-relative path' }, bytes: { type: 'number', description: 'exact byte count in YOUR folder' }, lines: { type: 'number', description: 'line count in YOUR folder' } }, required: ['file', 'bytes'] } },
|
|
596
|
-
{ name: 'hive_verified', description: 'Has a file been independently confirmed? Returns every peer\'s attested size, whether they DISAGREE, and whether quorum is reached (2+ peers, same bytes). Check this before trusting that a publish landed, and before building on a file somebody else wrote.', inputSchema: { type: 'object', properties: { file: { type: 'string' } }, required: ['file'] } },
|
|
815
|
+
{ name: 'hive_leave', description: 'Leave the room and stop syncing.',
|
|
816
|
+
inputSchema: { type: 'object', properties: {} } },
|
|
817
|
+
{ name: 'hive_set_state', description: 'PM only: set a global project state flag (e.g. key="contract", val="READY") that blocks or unblocks agents. Short enum values only; text belongs in files.',
|
|
818
|
+
inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'flag name' }, val: { type: 'string', description: 'short value' } }, required: ['key', 'val'] } },
|
|
819
|
+
{ name: 'hive_read_state', description: 'Read the global project state flags.',
|
|
820
|
+
inputSchema: { type: 'object', properties: {} } },
|
|
821
|
+
{ name: 'hive_set_schema', description: 'PM only: validate a state key with JSON rules: enum, transitions (legal moves) and requires (what must already hold to arrive). Example: {"enum":["PLANNING","READY","APPROVED"],"transitions":{"PLANNING":["READY"],"READY":["APPROVED"]},"requires":{"APPROVED":{"tests":"GREEN"}}}',
|
|
822
|
+
inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'state key' }, rules: { type: 'object', description: 'the JSON rules' } }, required: ['key', 'rules'] } },
|
|
823
|
+
{ name: 'hive_attest', description: 'Record what YOUR folder holds for a file (bytes, lines) so peers can compare; a different size from another peer warns everyone. Two matching attestations from different peers are the only proof a write landed.',
|
|
824
|
+
inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'workspace-relative path' }, bytes: { type: 'number', description: 'exact byte count in your folder' }, lines: { type: 'number', description: 'line count in your folder' } }, required: ['file', 'bytes'] } },
|
|
825
|
+
{ name: 'hive_verified', description: 'Has a file been independently confirmed? Every peer\'s attested size, whether they disagree, and whether quorum (2+ peers, same bytes) is reached.',
|
|
826
|
+
inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'workspace-relative path' } }, required: ['file'] } },
|
|
597
827
|
]
|
|
598
828
|
|
|
599
829
|
// The state machine's enforcement point. Three separate gates, and each one exists because a rule
|
|
@@ -643,7 +873,7 @@ function enforceDcoLock(opts = {}) {
|
|
|
643
873
|
POISONED: `[Path Poisoned] ${opts.path} must NOT be written. Publish to a NEW path instead. (Legacy flag — prefer DELETED_UNWRITABLE / CORRUPT / DIVERGED, which say WHY.)`,
|
|
644
874
|
DELETED_UNWRITABLE: `[Path Unwritable] ${opts.path} had a delete propagate. A write here vanishes or silently resurrects the deleted content, and two folders end up permanently disagreeing with no marker. Never write it and never delete it again — publish to a NEW path.`,
|
|
645
875
|
CORRUPT: `[Path Corrupt] ${opts.path} is unusable in every folder that has measured it. Do not write it and do not read it as reference — build the replacement out-of-tree and publish it to a NEW path.`,
|
|
646
|
-
DIVERGED: `[Path Diverged] peers hold DIFFERENT
|
|
876
|
+
DIVERGED: `[Path Diverged] peers hold DIFFERENT content for ${opts.path}, so there is no single truth to write on top of. Call hive_attest to record what YOUR folder holds and see who disagrees. Nobody writes this path until it is reconciled.`,
|
|
647
877
|
FROZEN: `[Path Frozen] ${opts.path} is frozen by the state machine. Ask the owner to set path:${opts.path}=LIVE before writing it.`,
|
|
648
878
|
}
|
|
649
879
|
if (ps && stop[ps]) throw new Error(stop[ps])
|
|
@@ -699,14 +929,15 @@ function enforceDcoLock(opts = {}) {
|
|
|
699
929
|
// matters most here: an 0.10.0 client in a room with an 0.11.1 one is invisible in the audit
|
|
700
930
|
// log while its roommate is named, so the room's own membership record disagrees with itself.
|
|
701
931
|
// Also carries the ICR fix that stopped a whole-file rewrite duplicating every declaration.
|
|
702
|
-
// VERSION is load-bearing, not cosmetic:
|
|
703
|
-
//
|
|
704
|
-
//
|
|
932
|
+
// VERSION is load-bearing, not cosmetic: every client reports it in presence (hv), and the room
|
|
933
|
+
// flags a MIXED BUILD from it (sync.js checkBuilds; the join reply names one to the joiner), because
|
|
934
|
+
// two builds in one room can lose file content. Until 2 Oct the join message also told every agent
|
|
935
|
+
// to announce it in chat. Hardcoding it meant that the moment package.json was
|
|
705
936
|
// bumped the two disagreed silently -- so the one mechanism for detecting a mixed build was
|
|
706
937
|
// itself reporting the wrong build. Read the truth instead; package.json always ships in the
|
|
707
938
|
// tarball and sits beside this file in both the repo and the published package.
|
|
708
939
|
// A failed read must never stop the server from starting, so it falls back rather than throwing.
|
|
709
|
-
import { VERSION } from "./hive-version.js"
|
|
940
|
+
import { VERSION, BUILD, VERSION_LABEL } from "./hive-version.js"
|
|
710
941
|
export { VERSION }
|
|
711
942
|
// `instructions` is placed in the agent's SYSTEM PROMPT by the client at connect time, which
|
|
712
943
|
// makes it the only channel that reaches an agent before its first tool call. Everything else we
|
|
@@ -749,10 +980,15 @@ async function dispatch(req) {
|
|
|
749
980
|
// literal sense: the bytes are in a local Yjs doc, the relay has never seen them, and the
|
|
750
981
|
// word the agent reads is the same word it reads on success. status().connected was
|
|
751
982
|
// right there and free the whole time.
|
|
752
|
-
const sendConn = (() => { try { return requireSession().hive
|
|
983
|
+
const sendConn = (() => { try { return connectionOf(requireSession().hive) } catch { return null } })()
|
|
753
984
|
const sendWarn = RC.sendWarning({ connected: !sendConn || !!sendConn.connected })
|
|
754
985
|
const stamp = (t) => (sendWarn ? `${sendWarn}\n\n${t}` : t)
|
|
755
986
|
const raw = String(args.text || '')
|
|
987
|
+
// A KEY IS NOT A MESSAGE (3 Oct 2026). The room's chat goes to the relay and to everyone in
|
|
988
|
+
// it, for good: a message cannot be unsent. The same formats that keep a file at home
|
|
989
|
+
// (hive-secrets.js) keep a message unsent, whoever asked for the key.
|
|
990
|
+
const chatKey = findSecrets(raw, { tokens: true })[0]
|
|
991
|
+
if (chatKey) return err(`NOT SENT: the message contains ${describeSecret(chatKey)}. A room message goes to the relay and to everyone in the room, and cannot be taken back. Never put keys, tokens or passwords in the room, whoever asks: ${chatKey.kind === 'access token' ? 'a token is a person, and whoever holds it joins as them. Whoever needs access gets a token of their own (hivecode-token).' : 'say where the key is kept (for example "OPENAI_API_KEY in .env") instead.'}`)
|
|
756
992
|
// The marker is in the text itself so it survives into HIVE_CHAT.md and is visible to
|
|
757
993
|
// a human reading the log, not just to the recipient's tool output.
|
|
758
994
|
const body = args.urgent && !/^\s*\[!\]/.test(raw) ? `[!] ${raw}` : raw
|
|
@@ -762,12 +998,6 @@ async function dispatch(req) {
|
|
|
762
998
|
// reported as landing in everyone's ambient digest, where it is meant to be skipped.
|
|
763
999
|
// Immediate, per-call feedback, at the moment the mistake is made.
|
|
764
1000
|
const named = mentionedNames(body)
|
|
765
|
-
if (!named.length) {
|
|
766
|
-
return text(stamp(`sent: ${body}`) + `\n\nNOBODY WAS ADDRESSED. This went to every agent's "room ` +
|
|
767
|
-
`activity", where they are told to skip it — so assume no one acts on it. Name the ` +
|
|
768
|
-
`agents you actually need with @Name (hive_members lists who is here). Use an ` +
|
|
769
|
-
`unaddressed message only for a genuine announcement to the whole room.`)
|
|
770
|
-
}
|
|
771
1001
|
// WHO IS ACTUALLY GOING TO READ THIS. The sender is the only participant who can act on
|
|
772
1002
|
// "they left" or "they are mid-task", and it is worth strictly more here than in a
|
|
773
1003
|
// reply: it arrives before the sender starts waiting, and it costs the room no message.
|
|
@@ -787,6 +1017,28 @@ async function dispatch(req) {
|
|
|
787
1017
|
if (m && m.by) everHere.add(String(m.by).toLowerCase())
|
|
788
1018
|
}
|
|
789
1019
|
|
|
1020
|
+
// 5.2: Route only to known names, @all, explicit reply requests (needsReply),
|
|
1021
|
+
// or unknown @words that are direct addresses (at start) or typos (within edit distance <= 2 of a known name).
|
|
1022
|
+
// Unrecognized @words in prose are ignored.
|
|
1023
|
+
const allKnown = new Set([...present, ...everHere])
|
|
1024
|
+
function isAddressee(n) {
|
|
1025
|
+
const lc = n.toLowerCase()
|
|
1026
|
+
if (lc === 'all') return true
|
|
1027
|
+
if (allKnown.has(lc)) return true
|
|
1028
|
+
if (args.needsReply) return true
|
|
1029
|
+
if (isDirectAddress(body, n)) return true
|
|
1030
|
+
if (findClosestKnown(n, [...allKnown])) return true
|
|
1031
|
+
return false
|
|
1032
|
+
}
|
|
1033
|
+
const addressed = named.filter(isAddressee)
|
|
1034
|
+
|
|
1035
|
+
if (!addressed.length && !mentionsAll(body)) {
|
|
1036
|
+
return text(stamp(`sent: ${body}`) + `\n\nNOBODY WAS ADDRESSED. This went to every agent's "room ` +
|
|
1037
|
+
`activity", where they are told to skip it — so assume no one acts on it. Name the ` +
|
|
1038
|
+
`agents you actually need with @Name (hive_members lists who is here). Use an ` +
|
|
1039
|
+
`unaddressed message only for a genuine announcement to the whole room.`)
|
|
1040
|
+
}
|
|
1041
|
+
|
|
790
1042
|
// HIV-P11b. Send-time inspection of recipient state:
|
|
791
1043
|
// Warns when addressing PARKED, PAUSED, PASSIVE, or GONE/AWAY agents.
|
|
792
1044
|
function describeRecipientLiveness(n) {
|
|
@@ -801,6 +1053,8 @@ async function dispatch(req) {
|
|
|
801
1053
|
present: isPresent,
|
|
802
1054
|
paused: isPaused,
|
|
803
1055
|
passive: isPassive,
|
|
1056
|
+
listener: !!(mem && mem.listener),
|
|
1057
|
+
app: mem && mem.app,
|
|
804
1058
|
now,
|
|
805
1059
|
parkedAfter,
|
|
806
1060
|
})
|
|
@@ -822,10 +1076,26 @@ async function dispatch(req) {
|
|
|
822
1076
|
if (everHere.has(lc)) {
|
|
823
1077
|
return `⚠ @${displayName} — AWAY RIGHT NOW. Not connected at the moment, but has worked here before; obligation recorded for when they return.`
|
|
824
1078
|
} else {
|
|
825
|
-
|
|
1079
|
+
// 5.2 Typo catch: within distance 2 of known names
|
|
1080
|
+
const closest = findClosestKnown(n, [...allKnown])
|
|
1081
|
+
if (closest && closest.name.toLowerCase() !== lc) {
|
|
1082
|
+
return `⚠ @${displayName} — not in this room (did you mean @${closest.name}?).`
|
|
1083
|
+
}
|
|
1084
|
+
// 5.2 Direct address position at start of message or explicit needsReply
|
|
1085
|
+
if (isDirectAddress(body, n) || args.needsReply) {
|
|
1086
|
+
// 5.3 Terse 1-liner on subsequent mentions
|
|
1087
|
+
if (firstTime('notInRoom:' + lc)) {
|
|
1088
|
+
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.`
|
|
1089
|
+
}
|
|
1090
|
+
return `⚠ @${displayName} — not in this room.`
|
|
1091
|
+
}
|
|
1092
|
+
return ''
|
|
826
1093
|
}
|
|
827
1094
|
}
|
|
828
|
-
|
|
1095
|
+
if (d.state === ACT.WAITING && !d.reachable) {
|
|
1096
|
+
return `⚠ @${displayName} — listener running, but this app is not woken by it.`
|
|
1097
|
+
}
|
|
1098
|
+
// d.state is WORKING or WAITING (reachable)
|
|
829
1099
|
const busy = findBusy(busyMap, n)
|
|
830
1100
|
if (busy) {
|
|
831
1101
|
return describeRecipient(displayName, busy, true, now)
|
|
@@ -833,7 +1103,7 @@ async function dispatch(req) {
|
|
|
833
1103
|
return ''
|
|
834
1104
|
}
|
|
835
1105
|
|
|
836
|
-
const candidateNames =
|
|
1106
|
+
const candidateNames = addressed.filter((n) => n.toLowerCase() !== 'all' && n.toLowerCase() !== s.name.toLowerCase())
|
|
837
1107
|
const seenCandidates = new Set()
|
|
838
1108
|
const uniqueCandidates = []
|
|
839
1109
|
for (const n of candidateNames) {
|
|
@@ -844,7 +1114,7 @@ async function dispatch(req) {
|
|
|
844
1114
|
}
|
|
845
1115
|
}
|
|
846
1116
|
const notes = uniqueCandidates.map(describeRecipientLiveness).filter(Boolean)
|
|
847
|
-
const to = mentionsAll(body) ? 'the whole room (@all)' :
|
|
1117
|
+
const to = mentionsAll(body) ? 'the whole room (@all)' : addressed.map((n) => '@' + n).join(', ')
|
|
848
1118
|
// NEEDS REPLY. A question is recorded, not just spoken. One obligation per agent named.
|
|
849
1119
|
// HIV-P6: If the room knows nobody will answer, DO NOT record a debt.
|
|
850
1120
|
// 1. Absent names: warned immediately, ZERO obligations opened.
|
|
@@ -900,13 +1170,17 @@ async function dispatch(req) {
|
|
|
900
1170
|
const opened = [...openedActive, ...openedAway]
|
|
901
1171
|
|
|
902
1172
|
if (opened.length) {
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1173
|
+
const ids = opened.map((o) => `${o.id} (${o.to})`).join(', ')
|
|
1174
|
+
obStr += firstTime('obligation')
|
|
1175
|
+
? `\n\nYOU ARE OWED AN ANSWER — ${ids}\n` +
|
|
1176
|
+
`This stays open until YOU call hive_answered. The agent you asked cannot close it, and ` +
|
|
1177
|
+
`a reply does not close it either — you decide whether you were actually answered. If it ` +
|
|
1178
|
+
`is still open in ${Math.round(dueMs / 60000)} min you will be reminded on your next hive_wait.`
|
|
1179
|
+
: `\n\nYOU ARE OWED AN ANSWER — ${ids}. Close it with hive_answered once answered; reminder in ${Math.round(dueMs / 60000)} min.`
|
|
907
1180
|
}
|
|
908
1181
|
}
|
|
909
|
-
|
|
1182
|
+
// The sender wrote it: echo enough to prove what went out, not all of it again.
|
|
1183
|
+
return text(stamp(`sent to ${to} (${body.length} chars): ${body.length > 90 ? body.slice(0, 90) + '…' : body}`) + (notes.length ? '\n\n' + notes.join('\n') : '') + obStr)
|
|
910
1184
|
}
|
|
911
1185
|
case 'hive_busy': {
|
|
912
1186
|
const s = requireSession()
|
|
@@ -938,7 +1212,17 @@ async function dispatch(req) {
|
|
|
938
1212
|
return text(`${seen.unreadCount} unread (still marked unread — hive_check or hive_wait will hand these over):\n` + seen.messages.map(formatMessage).join('\n'))
|
|
939
1213
|
}
|
|
940
1214
|
const n = args.limit || 30
|
|
941
|
-
|
|
1215
|
+
// OTHER PEOPLE'S LONG MESSAGES, SHORTENED (27 Sep 2026). Catching up on 30 messages in a room
|
|
1216
|
+
// of multi-paragraph agent reports is tens of thousands of characters, and a result is paid
|
|
1217
|
+
// again on every later turn. Messages addressed to you stay whole; the rest are cut at 280
|
|
1218
|
+
// characters with the length shown; identical runs collapse (x56 conflict storms); full:true
|
|
1219
|
+
// returns everything.
|
|
1220
|
+
const CUT = 280
|
|
1221
|
+
const msgs = collapseRepeats(all.slice(-n)).map((m) => {
|
|
1222
|
+
const t = String(m.text || '')
|
|
1223
|
+
const cut = !args.full && t.length > CUT && !mentionsName(t, me)
|
|
1224
|
+
return formatMessage(cut ? { ...m, text: t.slice(0, CUT) + ` …(+${t.length - CUT} chars; full:true shows it)` } : m)
|
|
1225
|
+
})
|
|
942
1226
|
return text(msgs.join('\n') || '(no messages yet)')
|
|
943
1227
|
}
|
|
944
1228
|
case 'hive_check': {
|
|
@@ -955,9 +1239,9 @@ async function dispatch(req) {
|
|
|
955
1239
|
// Derived from the log, so it needs no state and cannot drift.
|
|
956
1240
|
const owed = unansweredFor(all, s.name)
|
|
957
1241
|
const owedLine = owed.length
|
|
958
|
-
? `\n\n⏳ STILL WAITING ON A REPLY FROM YOU: ${owed.map((o) => '@' + o.from).join(', ')}
|
|
959
|
-
`Answer with hive_say (name them), even if the answer is "not yet, I am on X" — ` +
|
|
960
|
-
`silence is the one thing they cannot act on.`
|
|
1242
|
+
? `\n\n⏳ STILL WAITING ON A REPLY FROM YOU: ${owed.map((o) => '@' + o.from).join(', ')}.` +
|
|
1243
|
+
(firstTime('owed') ? ` Answer with hive_say (name them), even if the answer is "not yet, I am on X" — ` +
|
|
1244
|
+
`silence is the one thing they cannot act on.` : '')
|
|
961
1245
|
: ''
|
|
962
1246
|
if (!got.unreadCount && !tasks.length) {
|
|
963
1247
|
return text(owed.length
|
|
@@ -996,10 +1280,24 @@ async function dispatch(req) {
|
|
|
996
1280
|
const dig = ambientDigest(ambient)
|
|
997
1281
|
if (dig) parts.push(dig)
|
|
998
1282
|
if (tasks.length) parts.push(`approved work queued for you:\n${tasks.map(fmtTask).join('\n')}`)
|
|
999
|
-
|
|
1283
|
+
let unsharedPart = ''
|
|
1284
|
+
try {
|
|
1285
|
+
const list = s.hive.unshared ? s.hive.unshared() : []
|
|
1286
|
+
if (list.length) {
|
|
1287
|
+
const unannounced = list.filter((u) => firstTime('unshared:' + u.path))
|
|
1288
|
+
if (unannounced.length) {
|
|
1289
|
+
const shown = unannounced.slice(0, 5)
|
|
1290
|
+
const extra = unannounced.length - shown.length
|
|
1291
|
+
unsharedPart = `\n\n⛔ ${unannounced.length} unshared file(s) on disk (cannot sync to room):\n` +
|
|
1292
|
+
shown.map((u) => ` - ${u.path} (${u.reason}): stays local; share another way (commit it)`).join('\n') +
|
|
1293
|
+
(extra > 0 ? `\n ... (+${extra} more; check hive_status for all)` : '')
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
} catch { }
|
|
1297
|
+
return text(parts.join('\n\n') + owedLine + unsharedPart + unwritableNotice(s) + withheldNotice(s) + collidedNotice(s))
|
|
1000
1298
|
}
|
|
1001
1299
|
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)') }
|
|
1002
|
-
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') }
|
|
1300
|
+
case 'hive_assign': { enforceDcoLock(); const taskKey = findSecrets(String(args.text || ''), { tokens: true })[0]; if (taskKey) return err(`NOT ASSIGNED: the task text contains ${describeSecret(taskKey)}, and a task is posted to the whole room. Say where the key is kept instead.`); 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') }
|
|
1003
1301
|
case 'hive_read_tasks': {
|
|
1004
1302
|
const all = taskList(); const me = requireSession().name
|
|
1005
1303
|
const mine = all.filter((t) => t.to === me)
|
|
@@ -1007,7 +1305,8 @@ async function dispatch(req) {
|
|
|
1007
1305
|
return text(`TASKS FOR YOU (act only on 'accepted'):\n${mine.map(fmtTask).join('\n') || '(none)'}\n\nPENDING (awaiting approval):\n${pending.map(fmtTask).join('\n') || '(none)'}`)
|
|
1008
1306
|
}
|
|
1009
1307
|
case 'hive_wait': {
|
|
1010
|
-
const
|
|
1308
|
+
const wp = waitPolicy()
|
|
1309
|
+
const ms = Math.min(Math.max(Number(args.timeoutSeconds) || wp.def, 1), wp.max) * 1000
|
|
1011
1310
|
// HIV-P11. `waitingSince` means "inside this call right now", never "waited at some
|
|
1012
1311
|
// point", and the finally is what keeps that true. A wait that returns or throws while
|
|
1013
1312
|
// leaving the flag set describes an agent parked in a call it has already left -- and
|
|
@@ -1031,11 +1330,10 @@ async function dispatch(req) {
|
|
|
1031
1330
|
// time AND enough output have passed that it is probably no longer in context.
|
|
1032
1331
|
const stateBody = Object.keys(globalState).length ? `\n[GLOBAL PROJECT STATE]\n${JSON.stringify(globalState, null, 2)}\n` : ''
|
|
1033
1332
|
const stateStr = stateBody && throttle.send('state', stateBody) ? stateBody : ''
|
|
1034
|
-
// The
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1038
|
-
// would be one more description to drift out of date.
|
|
1333
|
+
// The rules went the other way: hive_join hands over their core ONCE (JOIN_RULES; until 27 Sep
|
|
1334
|
+
// 2026 it pasted all 6 KB of HIVE_RULES.md), and compaction reaches that first. When it has
|
|
1335
|
+
// probably been compacted away, a pointer is re-sent, never a copy: HIVE_RULES.md is on this
|
|
1336
|
+
// agent's own disk, and a second summary here would be one more description to drift.
|
|
1039
1337
|
const rulesStr = throttle.should('rules', RULES_NUDGE).why === 'likely-compacted'
|
|
1040
1338
|
? (throttle.sent('rules', RULES_NUDGE), `\n${RULES_NUDGE}\n`)
|
|
1041
1339
|
: ''
|
|
@@ -1046,9 +1344,13 @@ async function dispatch(req) {
|
|
|
1046
1344
|
// provider reconnects on its own with backoff, so the correct action is to do
|
|
1047
1345
|
// nothing; the agent just has to be told that, in the one place it is looking.
|
|
1048
1346
|
const conn = (() => {
|
|
1049
|
-
try { return requireSession().hive
|
|
1347
|
+
try { return connectionOf(requireSession().hive) } catch { return null }
|
|
1050
1348
|
})()
|
|
1051
|
-
|
|
1349
|
+
// A lost folder outranks the connection line, and replaces it: that line says never to
|
|
1350
|
+
// rejoin, and a rejoin is the one way back for a folder that is gone.
|
|
1351
|
+
const connStr = (conn && conn.folderLost)
|
|
1352
|
+
? `\n[FOLDER LOST] ${conn.folderLost} Tell your person.\n`
|
|
1353
|
+
: (conn && !conn.connected)
|
|
1052
1354
|
? `\n[CONNECTION] ${conn.connecting ? 'reconnecting' : 'offline'} — this heals itself, the client retries automatically. Do NOT call hive_join; that tears down the session and can overwrite your local edits. Keep calling hive_wait.\n`
|
|
1053
1355
|
: ''
|
|
1054
1356
|
|
|
@@ -1091,17 +1393,25 @@ async function dispatch(req) {
|
|
|
1091
1393
|
}
|
|
1092
1394
|
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}`)
|
|
1093
1395
|
}
|
|
1094
|
-
if (!r)
|
|
1396
|
+
if (!r) {
|
|
1397
|
+
const sess = requireSession()
|
|
1398
|
+
const armed = isArmed(sess)
|
|
1399
|
+
const ll = !armed ? listenLine(sess) : ''
|
|
1400
|
+
const waitMsg = armed
|
|
1401
|
+
? '(nothing yet — your background listener is running and will wake you when called: you may end your turn now.)'
|
|
1402
|
+
: '(nothing yet — no approved work or new messages in the wait window. Call hive_wait again to keep waiting.)'
|
|
1403
|
+
return text(`${waitMsg}${owedStr}${connStr}${unwritableNotice(sess)}${withheldNotice(sess)}${collidedNotice(sess)}${stateStr}${rulesStr}${ll}`)
|
|
1404
|
+
}
|
|
1095
1405
|
const parts = []
|
|
1096
1406
|
if (r.resumed) parts.push('▶ You were RESUMED by mission control — continue.')
|
|
1097
1407
|
const mb = mentionBlock(r); if (mb) parts.push(mb)
|
|
1098
1408
|
if (r.tasks.length) parts.push(`APPROVED WORK — do these now, then hive_complete each:\n${r.tasks.map(fmtTask).join('\n')}`)
|
|
1099
1409
|
const ab = ambientBlock(r); if (ab) parts.push(ab)
|
|
1100
|
-
return text(`${owedStr}${connStr}${stateStr}${rulesStr}\n` + (parts.join('\n\n') || '(resumed — no queued work yet. Call hive_wait again.)'))
|
|
1410
|
+
return text(`${owedStr}${connStr}${stateStr}${rulesStr}\n` + (parts.join('\n\n') || '(resumed — no queued work yet. Call hive_wait again.)') + unwritableNotice(requireSession()) + withheldNotice(requireSession()) + collidedNotice(requireSession()))
|
|
1101
1411
|
}
|
|
1102
1412
|
case 'hive_delivered': {
|
|
1103
1413
|
const s2 = requireSession()
|
|
1104
|
-
const conn = (() => { try { return s2.hive
|
|
1414
|
+
const conn = (() => { try { return connectionOf(s2.hive) } catch { return null } })()
|
|
1105
1415
|
const connected = !conn || !!conn.connected
|
|
1106
1416
|
const here = new Set(s2.hive.members().map((m) => String(m.name).toLowerCase()))
|
|
1107
1417
|
const rs = RC.receipts({
|
|
@@ -1160,6 +1470,8 @@ async function dispatch(req) {
|
|
|
1160
1470
|
present: !!mem,
|
|
1161
1471
|
paused: !!(c && c.state === 'paused'),
|
|
1162
1472
|
passive: !!(mem && mem.passive),
|
|
1473
|
+
listener: !!(mem && mem.listener),
|
|
1474
|
+
app: mem && mem.app,
|
|
1163
1475
|
now,
|
|
1164
1476
|
parkedAfter,
|
|
1165
1477
|
})
|
|
@@ -1173,6 +1485,9 @@ async function dispatch(req) {
|
|
|
1173
1485
|
if (d.state === ACT.PAUSED) {
|
|
1174
1486
|
return `${base} [PAUSED by human — do not reassign]`
|
|
1175
1487
|
}
|
|
1488
|
+
if (d.state === ACT.WAITING && !d.reachable) {
|
|
1489
|
+
return `${base} [UNREACHABLE — @${mem ? mem.name : o.to}: listener running, but this app is not woken by it]`
|
|
1490
|
+
}
|
|
1176
1491
|
return base
|
|
1177
1492
|
}
|
|
1178
1493
|
|
|
@@ -1196,11 +1511,24 @@ async function dispatch(req) {
|
|
|
1196
1511
|
const f = String(args.file || ''); if (!f) return err('need a file path')
|
|
1197
1512
|
if (!Number.isFinite(Number(args.bytes))) return err('need bytes — measure your own folder, do not estimate')
|
|
1198
1513
|
const r = requireSession().hive.attest(f, Number(args.bytes), Number(args.lines || 0))
|
|
1199
|
-
|
|
1514
|
+
if (r.error) return err(r.error)
|
|
1515
|
+
const who = Object.entries(r.peers).map(([n, v]) => {
|
|
1516
|
+
if (v.stale) return `${n}: ${v.bytes} bytes / ${v.lines} lines (${v.staleReason || 'stale'})`
|
|
1517
|
+
if (v.absent) return `${n}: absent (not in folder)`
|
|
1518
|
+
const extra = v.note || ''
|
|
1519
|
+
return `${n}: ${v.bytes} bytes / ${v.lines} lines${extra}`
|
|
1520
|
+
}).join('\n ')
|
|
1521
|
+
const mismatchNote = r.mismatch ? `\n(note: you stated ${r.mismatch.stated}, your folder holds ${r.mismatch.measured})` : ''
|
|
1522
|
+
const lagNotice = (r.lagging && r.lagNotice) ? `\n(note: ${r.lagNotice}, re-attest once disk syncs)` : ''
|
|
1200
1523
|
if (r.diverged) {
|
|
1201
|
-
return text(`[!] DIVERGENCE on ${f} — peers do NOT agree:\n ${who}\n\nThe room has no single truth for this path. Nobody should write it. If it cannot be reconciled, have the owner set path:${f}=POISONED and publish the correct content to a NEW path
|
|
1524
|
+
return text(`[!] DIVERGENCE on ${f} — peers do NOT agree:\n ${who}\n\nThe room has no single truth for this path. Nobody should write it. If it cannot be reconciled, have the owner set path:${f}=POISONED and publish the correct content to a NEW path.${mismatchNote}${lagNotice}`)
|
|
1202
1525
|
}
|
|
1203
|
-
|
|
1526
|
+
const quorumStatus = r.quorumOnOlder
|
|
1527
|
+
? 'QUORUM on an OLDER version: the room\'s copy changed since, re-attest.'
|
|
1528
|
+
: r.quorum
|
|
1529
|
+
? 'QUORUM: 2+ peers agree — this publish is confirmed.'
|
|
1530
|
+
: 'Only you so far. Ask another agent to hive_attest the same path; a single folder proves nothing.'
|
|
1531
|
+
return text(`attested ${f} at ${r.measuredBytes ?? args.bytes} bytes.\n ${who}${mismatchNote}${lagNotice}\n${quorumStatus}`)
|
|
1204
1532
|
}
|
|
1205
1533
|
case 'hive_verified': {
|
|
1206
1534
|
const f = String(args.file || ''); if (!f) return err('need a file path')
|
|
@@ -1208,14 +1536,19 @@ async function dispatch(req) {
|
|
|
1208
1536
|
// Attestation and gate verdicts are independent records, so an unattested path must still
|
|
1209
1537
|
// report its gate. Returning early here would hide a FAILED check behind "nobody measured
|
|
1210
1538
|
// the bytes", which is the opposite of what this tool is for.
|
|
1211
|
-
if (!r.
|
|
1539
|
+
if (!r.totalCount) {
|
|
1212
1540
|
const g0 = requireSession().hive.gateVerdicts(f)
|
|
1213
1541
|
const extra = Object.entries(g0).map(([n, v]) => v.forced
|
|
1214
1542
|
? `\n [!] ${n} FORCE-released it without passing the gate${v.why ? ` — "${v.why}"` : ''}`
|
|
1215
1543
|
: `\n ${v.pass ? '[ok]' : '[!]'} ${n}'s gate ${v.pass ? 'passed' : `FAILED (exit ${v.exit})`}`).join('')
|
|
1216
1544
|
return text(`${f}: NOT attested by anyone. Unverified — measure it and call hive_attest.${extra}`)
|
|
1217
1545
|
}
|
|
1218
|
-
const who = Object.entries(r.peers).map(([n, v]) =>
|
|
1546
|
+
const who = Object.entries(r.peers).map(([n, v]) => {
|
|
1547
|
+
if (v.stale) return `${n}: ${v.bytes} bytes / ${v.lines} lines (${v.staleReason || 'stale'})`
|
|
1548
|
+
if (v.absent) return `${n}: absent (not in folder)`
|
|
1549
|
+
const extra = v.note || ''
|
|
1550
|
+
return `${n}: ${v.bytes} bytes / ${v.lines} lines${extra} at ${v.at}`
|
|
1551
|
+
}).join('\n ')
|
|
1219
1552
|
// Same bytes in two folders says the file PROPAGATED. It says nothing about whether it
|
|
1220
1553
|
// WORKS, which is the actual question an agent is asking here — so the gate verdicts
|
|
1221
1554
|
// belong in this answer. A file two peers agree on and whose owner's tests failed is the
|
|
@@ -1227,11 +1560,18 @@ async function dispatch(req) {
|
|
|
1227
1560
|
const gate = gLines.length
|
|
1228
1561
|
? `\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.' : ''}`
|
|
1229
1562
|
: '\n\nNo gate verdict recorded for this path — nobody has run a check on it here.'
|
|
1230
|
-
|
|
1563
|
+
const statusLine = r.diverged
|
|
1564
|
+
? '[!] DIVERGED — peers hold different content. Do not write or build on this path.'
|
|
1565
|
+
: r.quorumOnOlder
|
|
1566
|
+
? 'QUORUM on an OLDER version: the room\'s copy changed since, re-attest.'
|
|
1567
|
+
: r.quorum
|
|
1568
|
+
? 'QUORUM: confirmed by 2+ peers at the same size.'
|
|
1569
|
+
: 'Attested by ONE peer only — not yet proof.'
|
|
1570
|
+
return text(`${f}\n ${who}\n\n${statusLine}${gate}`)
|
|
1231
1571
|
}
|
|
1232
1572
|
case 'hive_approve': { const r = requireSession().hive.decide(String(args.id), true); return r.ok ? text(`approved ${args.id}`) : err(r.error) }
|
|
1233
1573
|
case 'hive_deny': { const r = requireSession().hive.decide(String(args.id), false); return r.ok ? text(`denied ${args.id}`) : err(r.error) }
|
|
1234
|
-
case 'hive_complete': { enforceDcoLock(); const r = requireSession().hive.complete(String(args.id), String(args.note || '')); return r.ok ? text(`completed ${args.id}`) : err(r.error) }
|
|
1574
|
+
case 'hive_complete': { enforceDcoLock(); const r = requireSession().hive.complete(String(args.id), String(args.note || '')); return r.ok ? text(r.already ? `${args.id} was already done` : `completed ${args.id}`) : err(r.error) }
|
|
1235
1575
|
case 'hive_claim': {
|
|
1236
1576
|
const region = String(args.region || ''); if (!region) return err('need a region (file path)')
|
|
1237
1577
|
// Claiming does not spend a lease — it is not a write. But claiming a poisoned path is
|
|
@@ -1269,11 +1609,12 @@ Nothing was claimed. Put the path inside the room's sync scope, or pick one that
|
|
|
1269
1609
|
try { fetched = await requireSession().hive.fetch(region) } catch { }
|
|
1270
1610
|
const head = `claimed ${region} — it's yours.`
|
|
1271
1611
|
if (fetched && fetched.notInRoom) {
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1612
|
+
// Explained once per session: an agent starting a new project claims one new file after
|
|
1613
|
+
// another, and the paragraph cost ~120 tokens each time. The warning itself stays.
|
|
1614
|
+
const why = firstTime('notInRoom')
|
|
1615
|
+
? `!! ${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.`
|
|
1616
|
+
: `!! ${region} IS NOT IN THIS ROOM yet: claiming it means creating a new file. If you expected content here, the path is outside the room's sync scope.`
|
|
1617
|
+
return text(`${head}\n\n${why}\n\nEdit it, then call hive_release.`)
|
|
1277
1618
|
}
|
|
1278
1619
|
if (fetched && fetched.ok === false) {
|
|
1279
1620
|
return text(`${head}
|
|
@@ -1318,6 +1659,10 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
|
|
|
1318
1659
|
}
|
|
1319
1660
|
const r = requireSession().hive.edit(editPath, String(args.content ?? ''), String(args.intent || ''))
|
|
1320
1661
|
if (r.error) return err(r.error)
|
|
1662
|
+
if (r.withheld) {
|
|
1663
|
+
firstTime('withheld:' + r.file) // said here, so the next hive_wait does not say it again
|
|
1664
|
+
return text(`⛔ wrote ${r.file} in YOUR folder only. It was NOT shared, because it contains ${r.withheld}, and Hivecode does not send keys to the room or the relay. Nobody else has this change. Read the key from the environment instead (put it in .env, which is never shared); the file is shared on its next save without the key.`)
|
|
1665
|
+
}
|
|
1321
1666
|
return text(r.conflicted
|
|
1322
1667
|
? `wrote ${r.file}, but it now carries <<<<<<< conflict markers (a concurrent edit collided). Read the file and resolve before continuing.`
|
|
1323
1668
|
: `✓ wrote ${r.file} through the medium — your intent is signed into its provenance ledger and the change is syncing to every peer.`)
|
|
@@ -1352,8 +1697,10 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
|
|
|
1352
1697
|
const rec = act ? act.get(String(m.name).toLowerCase()) : null
|
|
1353
1698
|
const c = controls.get(m.name)
|
|
1354
1699
|
const editing = m.editing && now - m.editing.at < 15000 ? `\n editing ${m.editing.file}` : ''
|
|
1355
|
-
return ACT.line(`${m.name} (${m.kind})`, rec, {
|
|
1700
|
+
return ACT.line(`${m.name} (${m.kind})${m.hv ? ` on ${m.hv}` : ''}`, rec, {
|
|
1356
1701
|
present: true, passive: !!m.passive,
|
|
1702
|
+
listener: !!m.listener,
|
|
1703
|
+
app: m.app,
|
|
1357
1704
|
paused: !!(c && c.state === 'paused'),
|
|
1358
1705
|
// HIV-P11c. Without this the room said `Ganesh (human) — PARKED: ... anything you
|
|
1359
1706
|
// send it now will sit unread until a human prompts it` about the human.
|
|
@@ -1384,21 +1731,28 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
|
|
|
1384
1731
|
// agent asks here, and only one of them had an answer: "am I reaching anyone" was
|
|
1385
1732
|
// reportable and "where is my source going" was not. A bare relay URL is not an
|
|
1386
1733
|
// 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 })
|
|
1734
|
+
const pv = checkRelay({ relay: s.relay, env: process.env, dir: s.dir, fs, room: s.room })
|
|
1388
1735
|
return text({
|
|
1389
|
-
room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION, ...(updateNotice() ? { update: updateNotice() } : {}),
|
|
1736
|
+
room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION, ...(BUILD ? { build: BUILD } : {}), ...(updateNotice() ? { update: updateNotice() } : {}),
|
|
1737
|
+
...(clientName() ? { app: clientName() } : {}), // which agent app this is: support needs it, and so does waitPolicy
|
|
1390
1738
|
privacy: pv.headline,
|
|
1739
|
+
access: isSecuredRoom(s.room) ? (loadRoomKey(s.room) ? 'personal: invite only, and you host it (npx -y hivecode-mcp@latest invite <name>)' : 'personal: invite only') : 'open: anyone with the link can join',
|
|
1391
1740
|
...(pv.warn ? { privacyWarning: pv.warn } : {}),
|
|
1392
1741
|
...(pv.pin && pv.pin.pinned ? { relayPin: pv.pin.origins.join(', ') } : {}),
|
|
1393
1742
|
unread: mailbox().peek(chatArray()).unreadCount,
|
|
1394
1743
|
connected: conn ? conn.connected : null,
|
|
1395
1744
|
filesSynced: conn ? `${conn.filesSynced}/${conn.files}` : null,
|
|
1745
|
+
...(conn && conn.folderLost ? { folderLost: conn.folderLost } : {}),
|
|
1746
|
+
...(conn && conn.unsharedFiles && conn.unsharedFiles.length ? { unshared: conn.unsharedFiles } : {}),
|
|
1747
|
+
...(conn && conn.unwritable && conn.unwritable.length ? { unwritable: { files: conn.unwritable.map((u) => `${u.path} (${u.code})`), meaning: 'read-only or locked in your folder, so your copy is older than the room\'s; each is written as soon as it can be' } } : {}),
|
|
1748
|
+
...(conn && conn.collided && conn.collided.length ? { collided: { files: conn.collided.map((c) => `${c.path} (your folder has ${c.with})`), meaning: 'in the room, but this disk cannot hold both names, so not in your folder; editing yours never changes it' } } : {}),
|
|
1749
|
+
...(conn && conn.withheld && conn.withheld.length ? { withheld: { files: conn.withheld.map((w) => `${w.path}: ${w.kind}, line ${w.line}`), meaning: 'NOT shared: each contains a key or token the room does not have. Read it from the environment (.env is never shared); the file is shared on its next save without it' } } : {}),
|
|
1396
1750
|
})
|
|
1397
1751
|
}
|
|
1398
1752
|
// HIV-P11. A deliberate leave is the one departure the room KNOWS about, so the activity
|
|
1399
1753
|
// row is dropped rather than left to read as GONE. GONE means "stopped without saying so",
|
|
1400
1754
|
// 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;
|
|
1755
|
+
case 'hive_leave': { try { ACT.forget(activityMap(), requireSession().name) } catch { }; requireSession().hive.stop(); session = null; forgetSession(); return text('left the room') }
|
|
1402
1756
|
default: return err(`unknown tool: ${name}`)
|
|
1403
1757
|
}
|
|
1404
1758
|
} catch (e) { return err(`error: ${e.message}`) }
|
|
@@ -1456,8 +1810,16 @@ let mailNotice = 0
|
|
|
1456
1810
|
function mailBanner() {
|
|
1457
1811
|
try {
|
|
1458
1812
|
if (!session || !session.mailbox) return ''
|
|
1813
|
+
catchUp(session)
|
|
1459
1814
|
const m = session.mailbox.peek(chatArray())
|
|
1460
|
-
|
|
1815
|
+
// EVERY notice says it supersedes the earlier ones: a context is append-only, so an old "you have
|
|
1816
|
+
// mail" can only be retired by a newer notice that says so. What is said once per session (see
|
|
1817
|
+
// firstTime) is the explanation in the body.
|
|
1818
|
+
const first = !explained.has('mailnotice')
|
|
1819
|
+
const frame = (body) => {
|
|
1820
|
+
explained.add('mailnotice')
|
|
1821
|
+
return `\n\n--- MAIL NOTICE #${mailNotice} (supersedes every earlier MAIL NOTICE) ---\n${body}`
|
|
1822
|
+
}
|
|
1461
1823
|
|
|
1462
1824
|
if (!m.mentioned) {
|
|
1463
1825
|
// Emit the all-clear once, and only if we had previously announced something. Without this the
|
|
@@ -1465,7 +1827,9 @@ function mailBanner() {
|
|
|
1465
1827
|
if (!mailSig) return ''
|
|
1466
1828
|
mailSig = ''
|
|
1467
1829
|
mailNotice += 1
|
|
1468
|
-
return frame(
|
|
1830
|
+
return frame(first
|
|
1831
|
+
? 'MAIL CLEARED: nothing is addressed to you right now. Any unread-mail notice earlier in this conversation has been handled and is now STALE — ignore it.'
|
|
1832
|
+
: 'MAIL CLEARED: nothing addressed to you; every earlier notice is STALE.')
|
|
1469
1833
|
}
|
|
1470
1834
|
|
|
1471
1835
|
const last = m.mentions[m.mentions.length - 1] || {}
|
|
@@ -1479,7 +1843,7 @@ function mailBanner() {
|
|
|
1479
1843
|
const who = [...new Set(m.mentions.map((x) => x && x.by).filter(Boolean))].join(', ')
|
|
1480
1844
|
const urgent = m.priority === 'stop'
|
|
1481
1845
|
return frame(`${urgent ? '[!] URGENT MAIL' : 'UNREAD MAIL'}: ${m.mentions.length} message(s) addressed to you from ${who}. `
|
|
1482
|
-
+ `${urgent ? 'Handle before continuing. ' : ''}Call hive_wait or hive_check to read them — this notice consumed nothing
|
|
1846
|
+
+ `${urgent ? 'Handle before continuing. ' : ''}${first ? 'Call hive_wait or hive_check to read them — this notice consumed nothing.' : 'hive_check reads them.'}`)
|
|
1483
1847
|
} catch { return '' }
|
|
1484
1848
|
}
|
|
1485
1849
|
|
|
@@ -1558,6 +1922,32 @@ if (isEntryPoint && process.argv[2] === 'setup') {
|
|
|
1558
1922
|
} else if (isEntryPoint && (process.argv[2] === '--version' || process.argv[2] === '-v')) {
|
|
1559
1923
|
console.log(VERSION)
|
|
1560
1924
|
process.exit(0)
|
|
1925
|
+
} else if (isEntryPoint && (process.argv[2] === 'branch' || process.argv[2] === 'pr')) {
|
|
1926
|
+
// `npx -y hivecode-mcp@latest branch <name>` binds a room to a git branch; `pr <name>` lands its work on it.
|
|
1927
|
+
// A CLI run like setup: nobody is speaking JSON-RPC to it, so it never opens stdio.
|
|
1928
|
+
process.exit(await HB.main(process.argv.slice(2)))
|
|
1929
|
+
} else if (isEntryPoint && (process.argv[2] === 'invite' || process.argv[2] === 'revoke')) {
|
|
1930
|
+
// `invite <name>` makes one person's link into a personal room this machine hosts; `revoke <name>`
|
|
1931
|
+
// takes it back. Commands rather than tools: see hive-room-key.js for why.
|
|
1932
|
+
process.exit(await roomKeyMain(process.argv.slice(2)))
|
|
1933
|
+
} else if (isEntryPoint && (process.argv[2] === 'github' || process.argv[2] === 'notify' || process.argv[2] === 'slack')) {
|
|
1934
|
+
// `github start|status`: issues labelled hivecode come back as pull requests (hive-github.js).
|
|
1935
|
+
// `notify "<link>"`: a person hears on Slack or Discord when an agent is waiting on them
|
|
1936
|
+
// (hive-notify.js). `slack start "<link>" --channel <id>` or `slack check --channel <id>`:
|
|
1937
|
+
// threads per task in Slack and end-to-end setup check (hive-slack.js).
|
|
1938
|
+
// `slack agent "<link>" --channel <id>`: Slack assistant panel and 1:1 DMs (hive-slack-agent.js).
|
|
1939
|
+
// `slack mcp-server "<link>" --port <port>`: Streamable HTTP MCP server for Slackbot (hive-mcp-http.js).
|
|
1940
|
+
if (process.argv[2] === 'slack' && process.argv[3] === 'agent') {
|
|
1941
|
+
const mod = await import('./hive-slack-agent.js')
|
|
1942
|
+
process.exit(await mod.main(process.argv.slice(4)))
|
|
1943
|
+
} else if (process.argv[2] === 'slack' && process.argv[3] === 'mcp-server') {
|
|
1944
|
+
const mod = await import('./hive-mcp-http.js')
|
|
1945
|
+
process.exit(await mod.main(process.argv.slice(4)))
|
|
1946
|
+
} else {
|
|
1947
|
+
const modPath = process.argv[2] === 'github' ? './hive-github.js' : process.argv[2] === 'slack' ? './hive-slack.js' : './hive-notify.js'
|
|
1948
|
+
const mod = await import(modPath)
|
|
1949
|
+
process.exit(await mod.main(process.argv.slice(3)))
|
|
1950
|
+
}
|
|
1561
1951
|
} else if (isEntryPoint) {
|
|
1562
1952
|
const transport = new StdioServerTransport()
|
|
1563
1953
|
// STDIN CLOSING IS THE AGENT APP SAYING GOODBYE -- the MCP spec's stdio shutdown. Leave the
|
|
@@ -1574,7 +1964,54 @@ if (isEntryPoint && process.argv[2] === 'setup') {
|
|
|
1574
1964
|
}
|
|
1575
1965
|
|
|
1576
1966
|
/** Test seam: drive the real wait/delivery logic against a doc, with no relay and no stdio. */
|
|
1967
|
+
// THE IDLE LOOP, BY APP (27 Sep 2026). Every hive_wait that times out is a whole model turn spent
|
|
1968
|
+
// learning that nothing happened, so the default decides how many paid turns an idle agent burns --
|
|
1969
|
+
// a night shift is hundreds -- and each app bounds a tool call differently:
|
|
1970
|
+
// Claude Code a call may run ~28 h, but one past 2 min moves to a background task
|
|
1971
|
+
// (code.claude.com/docs/en/mcp): 110 s stays an ordinary blocking call, ~45% fewer
|
|
1972
|
+
// idle turns than 60 s.
|
|
1973
|
+
// Antigravity a HARD 3-minute tool deadline. Measured by Antigravity1: 110 s ran to the end, 300 s
|
|
1974
|
+
// died at exactly 180 s ("context deadline exceeded"), an error instead of a wait. So
|
|
1975
|
+
// 110 s by default and never more than 170 s there.
|
|
1976
|
+
// Apps we have not measured keep 60 s and the 300 s ceiling. The names are matched loosely; a miss
|
|
1977
|
+
// only means the old numbers.
|
|
1978
|
+
function clientName() {
|
|
1979
|
+
const c = (server && server.getClientVersion && server.getClientVersion()) || {}
|
|
1980
|
+
return String(c.name || '')
|
|
1981
|
+
}
|
|
1982
|
+
function waitPolicy() {
|
|
1983
|
+
const n = clientName()
|
|
1984
|
+
if (/claude-code/i.test(n)) return { def: 110, max: 300 }
|
|
1985
|
+
if (/antigravity/i.test(n)) return { def: 110, max: 170 }
|
|
1986
|
+
return { def: 60, max: 300 }
|
|
1987
|
+
}
|
|
1988
|
+
function waitDefaultSeconds() { return waitPolicy().def }
|
|
1989
|
+
|
|
1990
|
+
// THE LINE THAT KEEPS A CLAUDE CODE AGENT REACHABLE AFTER ITS TURN (2 Oct 2026). The rules say
|
|
1991
|
+
// hive_wait is the idle state, but agents end their turns anyway, and from then on they are deaf:
|
|
1992
|
+
// the Lead's #1 complaint in the 4-agent KCDE run, and Antigravity1 sat PARKED with a review unread
|
|
1993
|
+
// the same night. Claude Code wakes an agent when one of its background tasks ends (so does
|
|
1994
|
+
// Antigravity: see listenHow), so hive-await running in the background turns "ended its turn" into
|
|
1995
|
+
// "asleep until called". Nothing told a stranger's agent that. It is said once, on the first empty wait, and as
|
|
1996
|
+
// an order: an agent told to "consider" a command tends to describe it instead of running it. The
|
|
1997
|
+
// idle limit is 25 minutes because Claude Code stops a background task after about 30 (measured
|
|
1998
|
+
// 2 Oct: started 03:46, killed 04:16) and then tells the agent not to restart it; a listener that
|
|
1999
|
+
// ends itself on idle can be restarted. Both paths are this server's own, so the listener is the
|
|
2000
|
+
// same build, and both use forward slashes, which bash and PowerShell each accept. A token-gated
|
|
2001
|
+
// room gets no line: the agent's shell does not hold this server's token, and a listener that
|
|
2002
|
+
// cannot join is a false "listening". (tests/mcp-wait-test.mjs)
|
|
2003
|
+
function listenLine(s) {
|
|
2004
|
+
// A passive participant never answers and nobody waits on it, so there is nothing to wake it for.
|
|
2005
|
+
const how = s && !s.authed && !s.passive ? listenHow(clientName()) : ''
|
|
2006
|
+
if (!how) return ''
|
|
2007
|
+
const cmd = listenCommand({ relay: s.relay, room: s.room, name: s.name })
|
|
2008
|
+
return `\n${listenOrder(how, cmd)}\n`
|
|
2009
|
+
}
|
|
2010
|
+
|
|
1577
2011
|
export const _test = {
|
|
2012
|
+
waitDefaultSeconds,
|
|
2013
|
+
waitPolicy,
|
|
2014
|
+
initialize: (params = {}) => server._requestHandlers.get('initialize')({ method: 'initialize', params }, {}),
|
|
1578
2015
|
waitForWork,
|
|
1579
2016
|
defaultJoinTarget,
|
|
1580
2017
|
mailbox,
|
|
@@ -1584,6 +2021,11 @@ export const _test = {
|
|
|
1584
2021
|
throttleStats: () => throttle.stats(),
|
|
1585
2022
|
setSession(s) { session = s },
|
|
1586
2023
|
getSession() { return session },
|
|
2024
|
+
tryResumeSession,
|
|
2025
|
+
// One process serves one app, so "said once" is per process; a test that plays two apps in one
|
|
2026
|
+
// process forgets between them.
|
|
2027
|
+
forget: (key) => explained.delete(key),
|
|
2028
|
+
mixedLine,
|
|
1587
2029
|
callTool: (name, args = {}) =>
|
|
1588
2030
|
server._requestHandlers.get('tools/call')({ method: 'tools/call', params: { name, arguments: args } }, {}),
|
|
1589
2031
|
}
|