hivecode-mcp 0.12.2 → 0.12.4
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/LICENSE +21 -21
- package/README.md +187 -8
- package/core.js +91 -91
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +5 -5
- package/docs/SELF_HOSTING.md +256 -256
- package/hive-activity.js +336 -310
- package/hive-await.js +187 -0
- package/hive-branch.js +656 -0
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-hook-install.js +179 -103
- package/hive-instructions.js +74 -66
- package/hive-listen.js +81 -0
- package/hive-mailbox.js +517 -386
- package/hive-mcp.js +483 -126
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +350 -350
- package/hive-obligations.js +247 -247
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +337 -0
- package/hive-privacy.js +327 -288
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +7 -2
- package/hive-secrets.js +89 -0
- package/hive-setup.js +548 -445
- package/hive-stdio.js +172 -168
- package/hive-stop-hook.js +290 -241
- package/hive-throttle.js +118 -118
- package/hive-update.js +58 -58
- package/hive-version.js +110 -59
- package/icr-merge.js +58 -58
- package/icr.js +121 -15
- 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 +19 -11
- package/reconcile.cjs +614 -477
- package/substrate.js +252 -252
- package/sync.js +4691 -3011
- package/grm-link.cjs +0 -141
package/hive-mcp.js
CHANGED
|
@@ -36,15 +36,18 @@ 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 { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName } from './hive-mailbox.js'
|
|
41
|
-
import { INSTRUCTIONS } from './hive-instructions.js'
|
|
39
|
+
import { checkRelay, currentRelayFor, controlUrl, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
|
|
40
|
+
import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName, collapseRepeats, findClosestKnown, isDirectAddress } from './hive-mailbox.js'
|
|
41
|
+
import { INSTRUCTIONS, JOIN_RULES } from './hive-instructions.js'
|
|
42
42
|
import { makeBusy, isBusy, describeRecipient } from './hive-busy.js'
|
|
43
43
|
import * as ACT from './hive-activity.js' // HIV-P11; key MUST be 'activity'
|
|
44
44
|
import * as RH from './hive-relay-health.js' // HIV-P13; the ONLY producer of the not-syncing diagnosis
|
|
45
45
|
import * as OB from './hive-obligations.js'
|
|
46
46
|
import * as RC from './hive-receipts.js'
|
|
47
|
+
import { findSecrets, describeSecret } from './hive-secrets.js' // a key never goes into the room's chat or tasks either
|
|
47
48
|
import { makeThrottle, RULES_NUDGE } from './hive-throttle.js'
|
|
49
|
+
import * as HB from './hive-branch.js' // a room bound to a git branch: hive_join { branch }, and the branch/pr CLI
|
|
50
|
+
import { isArmed, armedFile, listenCommand, listenHow, listenOrder, LISTEN_IDLE_MIN, AWAIT_JS } from './hive-listen.js'
|
|
48
51
|
|
|
49
52
|
const DEFAULT_RELAY = process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY // written once, in hive-privacy.js
|
|
50
53
|
let session = null // { hive, room, relay, dir, name, mailbox }
|
|
@@ -87,11 +90,16 @@ function resolveRoomRaw(link, dir) {
|
|
|
87
90
|
// its own MCP process from its own directory) resumes only its own identity. Lets the
|
|
88
91
|
// session survive MCP host restarts, which otherwise silently drop the agent from the room.
|
|
89
92
|
const SESSION_FILE = path.join(process.cwd(), '.hive-mcp-session.json')
|
|
90
|
-
function persistSession({ dir, name, owner, cursor }) {
|
|
93
|
+
function persistSession({ dir, name, owner, cursor, authed = false, kind = 'ai', passive = false }) {
|
|
91
94
|
try {
|
|
92
95
|
const prev = readSessionFile() || {}
|
|
93
96
|
const chatCursor = cursor === undefined ? prev.chatCursor : cursor
|
|
94
|
-
|
|
97
|
+
// `authed`: the Stop hook reads this file, and must not order a listener into a room that checks
|
|
98
|
+
// tokens -- the agent's shell does not hold this server's token, so that listener could not join.
|
|
99
|
+
// `kind` and `passive`: a resume rejoins from this file, and without them a human came back as an
|
|
100
|
+
// AI and a passive participant as one the room waits on; the Stop hook reads `passive` too.
|
|
101
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify({ dir, name, owner, chatCursor, ...(authed ? { authed: true } : {}),
|
|
102
|
+
...(kind && kind !== 'ai' ? { kind } : {}), ...(passive ? { passive: true } : {}) }))
|
|
95
103
|
} catch { /* best-effort */ }
|
|
96
104
|
}
|
|
97
105
|
function readSessionFile() {
|
|
@@ -100,7 +108,7 @@ function readSessionFile() {
|
|
|
100
108
|
/** Save the read cursor so a host restart does not reopen the blind window it just closed. */
|
|
101
109
|
function persistCursor() {
|
|
102
110
|
if (!session || !session.mailbox) return
|
|
103
|
-
persistSession({ dir: session.dir, name: session.name, owner: session.owner, cursor: session.mailbox.cursor })
|
|
111
|
+
persistSession({ dir: session.dir, name: session.name, owner: session.owner, cursor: session.mailbox.cursor, authed: session.authed, kind: session.kind, passive: session.passive })
|
|
104
112
|
// AND PUBLISH IT INTO THE ROOM. The file above is for resuming THIS session; the room copy is
|
|
105
113
|
// what makes "did they actually read it" answerable by anybody other than me. The cursor only
|
|
106
114
|
// advances inside box.take(), which is the call that hands messages to the agent -- so a cursor
|
|
@@ -158,7 +166,7 @@ function defaultJoinTarget(link, name) {
|
|
|
158
166
|
return { dir: path.join(HIVE_HOME, safeSegment(room), safeSegment(name)), link, hostedHere: false }
|
|
159
167
|
}
|
|
160
168
|
|
|
161
|
-
async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false }) {
|
|
169
|
+
async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false, branch = '', repo = '' }) {
|
|
162
170
|
// REFUSED BEFORE THE OLD SESSION IS TORN DOWN. joinRoom's first act is to stop whatever session
|
|
163
171
|
// exists, so validating after that point would leave an agent with a bad name holding nothing at
|
|
164
172
|
// all -- it would lose a working room to a refusal it could have been given for free.
|
|
@@ -170,6 +178,21 @@ Nothing was joined and your current session is untouched. Re-run hive_join with
|
|
|
170
178
|
|
|
171
179
|
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
180
|
}
|
|
181
|
+
// A BRANCH ROOM (hive-branch.js): the link comes from the repo's git config, and the folder is
|
|
182
|
+
// this participant's own, marked so git refuses to run in it. Resolved and refused BEFORE the old
|
|
183
|
+
// session is torn down, for the same reason as the name check above.
|
|
184
|
+
let branchCtx = null
|
|
185
|
+
if (branch) {
|
|
186
|
+
const untouched = '\n\nNothing was joined and your current session is untouched.'
|
|
187
|
+
if (link) return `Pass branch or link, not both: a branch's room link comes from the repo's git config.${untouched}`
|
|
188
|
+
const bound = HB.resolveForJoin({ branch, repo: repo || process.cwd() })
|
|
189
|
+
if (!bound.ok) return bound.message + untouched
|
|
190
|
+
link = bound.link
|
|
191
|
+
if (!dir) dir = HB.roomFolder(link, name)
|
|
192
|
+
const mark = HB.markNotACheckout(path.resolve(dir))
|
|
193
|
+
if (!mark.ok) return `Q564 ${mark.why}${untouched}`
|
|
194
|
+
branchCtx = bound
|
|
195
|
+
}
|
|
173
196
|
if (session) { try { session.hive.stop() } catch {} ; session = null }
|
|
174
197
|
let hostedHere = false
|
|
175
198
|
if (!dir) ({ dir, link, hostedHere } = defaultJoinTarget(link, name))
|
|
@@ -182,7 +205,7 @@ This is refused rather than fixed for you because renaming silently is worse: if
|
|
|
182
205
|
// nothing behind -- no config pointing at the wrong relay for the next process to find,
|
|
183
206
|
// and above all no published file. Checking after connecting would report a leak instead
|
|
184
207
|
// 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 })
|
|
208
|
+
const priv = checkRelay({ relay, env: process.env, dir: ROOT, fs, room })
|
|
186
209
|
if (!priv.ok) {
|
|
187
210
|
return `${priv.headline}
|
|
188
211
|
|
|
@@ -216,7 +239,7 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
216
239
|
// folder is precisely the situation the lock exists to prevent.
|
|
217
240
|
let hive
|
|
218
241
|
try {
|
|
219
|
-
hive = startSync({ relay, room, dir, name, kind: kind || 'ai', owner, token: useToken, log: () => {}, passive: !!passive })
|
|
242
|
+
hive = startSync({ relay, room, dir, name, kind: kind || 'ai', owner, token: useToken, log: () => {}, passive: !!passive, app: clientName() })
|
|
220
243
|
} catch (e) {
|
|
221
244
|
if (e && e.code === 'EHIVELOCKED') {
|
|
222
245
|
return text(
|
|
@@ -226,8 +249,8 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
226
249
|
}
|
|
227
250
|
throw e
|
|
228
251
|
}
|
|
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)
|
|
252
|
+
session = { hive, room, relay, dir, name, owner, kind: kind || 'ai', passive: !!passive, authed: !!useToken, mailbox: null }
|
|
253
|
+
persistSession({ dir, name, owner, authed: !!useToken, kind: kind || 'ai', passive: !!passive }) // survive MCP host restarts (see tryResumeSession)
|
|
231
254
|
// Wait for the first sync so members/board/rules are ready.
|
|
232
255
|
//
|
|
233
256
|
// REPORT THE TRUTH ABOUT THIS. Until 18 Sep 2026 the timeout below simply resolved and the
|
|
@@ -259,7 +282,7 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
259
282
|
// authoritative cause (Q430 -> capacity, Q431 -> HIVE_SEATS, 401/403 -> token).
|
|
260
283
|
// Only when nothing refused us with a code do we query /health to infer whether it was capacity.
|
|
261
284
|
if (refusedCode) {
|
|
262
|
-
const explanation = RH.explainStatus(refusedCode, relay)
|
|
285
|
+
const explanation = RH.explainStatus(refusedCode, relay, { hasToken: !!useToken })
|
|
263
286
|
return [
|
|
264
287
|
`NOT JOINED -- the relay refused the connection (${refusedCode}): ${explanation}`,
|
|
265
288
|
`room: ${room}`,
|
|
@@ -296,6 +319,29 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
296
319
|
'it reports `connected` and `filesSynced`, and both are authoritative.',
|
|
297
320
|
].join('\n')
|
|
298
321
|
}
|
|
322
|
+
// ONE NAME, ONE PARTICIPANT (Q581). Everything in a room is keyed by name, so a second agent under
|
|
323
|
+
// a name already here is the same identity to everybody: both are told they hold each other's
|
|
324
|
+
// claims, both read each other's mail, and their writes land on each other's files. Measured
|
|
325
|
+
// 3 Oct 2026 -- see sync.js sameNameElsewhere. Refused here, before this session does anything.
|
|
326
|
+
let dupes = []
|
|
327
|
+
try { dupes = typeof hive.sameNameElsewhere === 'function' ? hive.sameNameElsewhere() : [] } catch { /* an engine that cannot tell */ }
|
|
328
|
+
if (dupes.length) {
|
|
329
|
+
const taken = new Set(hive.members().map((m) => String(m.name).toLowerCase()))
|
|
330
|
+
let n = 2
|
|
331
|
+
while (taken.has(`${name}-${n}`.toLowerCase())) n++
|
|
332
|
+
const d = dupes[0]
|
|
333
|
+
const who = [d.kind, d.owner ? `owner ${d.owner}` : '', d.app].filter(Boolean).join(', ')
|
|
334
|
+
try { hive.stop() } catch { }
|
|
335
|
+
session = null
|
|
336
|
+
try { fs.rmSync(SESSION_FILE) } catch { }
|
|
337
|
+
return [
|
|
338
|
+
`NOT JOINED -- Q581: ${d.name} is already in this room (${who}), and two participants cannot share a name.`,
|
|
339
|
+
'Everything here is keyed by name: you would each be told you hold the other\'s claims, read the',
|
|
340
|
+
'other\'s mail, and show as one member, and your writes would land on each other\'s files.',
|
|
341
|
+
`Join again with a different name, e.g. hive_join { name: "${name}-${n}" } with the same link.`,
|
|
342
|
+
`If that ${d.name} is you a moment ago on this machine, wait 30 seconds for it to drop out, then join again.`,
|
|
343
|
+
].join('\n')
|
|
344
|
+
}
|
|
299
345
|
// The mailbox is created AFTER the first sync, so chat.length is the real room history and
|
|
300
346
|
// not 0 — otherwise a fresh joiner would be handed the entire backlog as unread mail.
|
|
301
347
|
// A resumed session carries its saved cursor forward instead, so anything posted while this
|
|
@@ -306,6 +352,7 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
306
352
|
persistCursor()
|
|
307
353
|
|
|
308
354
|
const others = hive.members().filter((m) => m.name !== name).map((m) => `${m.name}(${m.kind})`)
|
|
355
|
+
const mixedNote = mixedLine(hive.members(), name, VERSION_LABEL)
|
|
309
356
|
|
|
310
357
|
// Event-Driven Wakeups: notify the MCP client the millisecond the DCO state changes
|
|
311
358
|
hive.doc.getMap('swarm_state').observe(() => {
|
|
@@ -348,6 +395,7 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
348
395
|
try {
|
|
349
396
|
const s = session
|
|
350
397
|
if (!s || !s.mailbox) return
|
|
398
|
+
catchUp(s)
|
|
351
399
|
const m = s.mailbox.peek(hive.doc.getArray('chat').toArray())
|
|
352
400
|
// Compare with !== rather than >: after hive_wait consumes the backlog the count drops to
|
|
353
401
|
// 0, and a later single mention must still ring. `>` would go permanently silent.
|
|
@@ -382,12 +430,24 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
382
430
|
// The sync engine writes HIVE_RULES.md into the folder on first sync — return it inline
|
|
383
431
|
// so the agent gets the rules with its join. (Fixes a ReferenceError: `rules` was never
|
|
384
432
|
// defined, which made every hive_join return "error: rules is not defined".)
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
//
|
|
390
|
-
|
|
433
|
+
// THE CORE, NOT THE WHOLE FILE. This reply stays in the agent's transcript for the rest of its
|
|
434
|
+
// session, so every character is paid again on every later turn: pasting all of HIVE_RULES.md made
|
|
435
|
+
// the join ~1,900 tokens (27 Sep 2026, bench/token-cost.mjs). The full text is on disk in the folder.
|
|
436
|
+
const rulesText = JOIN_RULES
|
|
437
|
+
// A TOKEN IS A PERSON (3 Oct 2026). The invite link used to carry the joiner's own token, labelled
|
|
438
|
+
// as the thing to forward, so an agent that shared it handed whoever read it the joiner's identity
|
|
439
|
+
// and access -- in the README's own flow, the host's maintainer token to the outside agent scoped to
|
|
440
|
+
// one folder. What is meant for others never carries a token now; the joiner's own Control Room
|
|
441
|
+
// link, which needs one, says whose it is; and a room that checks tokens says each person needs
|
|
442
|
+
// their own (a secured room rejects a link without one, which is the point).
|
|
443
|
+
const inviteLink = `${relay}|${room}`
|
|
444
|
+
// What a person without an agent clicks. Pasting wss:// into a browser's address bar searches for it.
|
|
445
|
+
const browserUrl = controlUrl(inviteLink)
|
|
446
|
+
const ownControl = useToken ? controlUrl(`${relay}|${room}|${useToken}`) : ''
|
|
447
|
+
const tokenNote = useToken
|
|
448
|
+
? `\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.` +
|
|
449
|
+
(ownControl ? `\nyour Control Room (carries YOUR token: open it yourself, never share it): ${ownControl}` : '')
|
|
450
|
+
: ''
|
|
391
451
|
// THE VERDICT, NOT JUST THE URL. `relay: wss://...` on its own is unreadable to anybody
|
|
392
452
|
// who has not run a relay -- the public one and a company's own look identical. The
|
|
393
453
|
// bracketed verdict is what makes "did my source just leave the building?" answerable
|
|
@@ -399,9 +459,46 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
399
459
|
? `\n\nMOVED: this folder pointed at ${migratedFrom}, a Hivecode relay that has been retired. ` +
|
|
400
460
|
`It now uses ${relay}, and .hive.json has been updated, so this happens once.`
|
|
401
461
|
: ''
|
|
402
|
-
|
|
462
|
+
// What the room cannot carry (binary, over 1 MB, always-ignored) comes from git, once the room's
|
|
463
|
+
// own files are known. Only those: a text file missing here was deleted in the room.
|
|
464
|
+
let branchNote = ''
|
|
465
|
+
if (branchCtx) {
|
|
466
|
+
let filled = 0
|
|
467
|
+
let fillErr = ''
|
|
468
|
+
if (branchCtx.commit) { try { filled = HB.fillFromGit({ root: branchCtx.root, commit: branchCtx.commit, dir: ROOT }) } catch (e) { fillErr = e.message } }
|
|
469
|
+
branchNote = `BRANCH ROOM: this room is git branch "${branchCtx.branch}" of ${branchCtx.root}.\n` +
|
|
470
|
+
`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}` +
|
|
471
|
+
(filled ? `\nFilled from git: ${filled} file(s) the room does not carry (binary, over 1 MB, or never synced).` : '') +
|
|
472
|
+
(fillErr ? `\nCould not fill non-text files from git (${fillErr}); binary files may be missing here.` : '') + '\n\n'
|
|
473
|
+
}
|
|
474
|
+
// THE INVITE, READY TO FORWARD (onboarding audit, 2 Oct 2026). The host used to assemble it by
|
|
475
|
+
// hand: the install, the reload step, the sentence, the link. setup given a link prints that
|
|
476
|
+
// person's reload steps and their exact sentence, so one message covers all of it. Host only:
|
|
477
|
+
// this reply stays in the transcript, and a joiner already has the link it came in by.
|
|
478
|
+
// One line per thing a person acts on (the stranger walk, 3 Oct: as one line it pasted into chat
|
|
479
|
+
// apps as a wall of links).
|
|
480
|
+
const people = browserUrl ? `\n No AI agent? Open ${browserUrl}` : ''
|
|
481
|
+
const invite = /^HOSTED/.test(mode)
|
|
482
|
+
? `\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}`
|
|
483
|
+
: (browserUrl ? `\nbrowser (people without an agent): ${browserUrl}` : '')
|
|
484
|
+
// No "announce your version" rule any more: since 29 Sep the room flags a mixed build itself
|
|
485
|
+
// (sync.js checkBuilds), and the joiner is told here, by name, when one is already in the room.
|
|
486
|
+
return `${branchNote}${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION_LABEL}\nothers here: ${others.join(', ') || 'none yet'}${mixedNote}\ninvite link: ${inviteLink}${tokenNote}${invite}${movedNote}${privNote}${updateNotice() ? '\n\n' + updateNotice() : ''}\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// A MIXED BUILD, TOLD TO THE ONE WHO CAN ACT ON IT (2 Oct 2026). The room's own warning names
|
|
490
|
+
// nobody, and agents are told to skip messages that name nobody; the joiner can act, so the join
|
|
491
|
+
// reply names every AI member whose reported build differs. Humans carry no engine build, and a
|
|
492
|
+
// client that reports none is the room's warning to give (checkBuilds speaks for old engines).
|
|
493
|
+
function mixedLine(members, me, label) {
|
|
494
|
+
const diff = (members || []).filter((m) => m && m.name && m.name !== me && String(m.kind || '').toLowerCase() !== 'human' && m.hv && m.hv !== label)
|
|
495
|
+
if (!diff.length) return ''
|
|
496
|
+
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
497
|
}
|
|
404
498
|
|
|
499
|
+
// Connected-or-not without status()'s file counts, which stat every file in the room. Falls back
|
|
500
|
+
// to status() for a hive that predates connection() (a test double, an older engine).
|
|
501
|
+
const connectionOf = (hive) => (typeof hive.connection === 'function' ? hive.connection() : hive.status())
|
|
405
502
|
function requireSession() { if (!session) throw new Error('not in a room — call hive_join first'); return session }
|
|
406
503
|
/** The read cursor for this participant. Lazily built if a session predates it. */
|
|
407
504
|
function mailbox() {
|
|
@@ -411,8 +508,24 @@ function mailbox() {
|
|
|
411
508
|
// rather than two that disagree.
|
|
412
509
|
try { throttle.sent('rules', RULES_NUDGE) } catch { }
|
|
413
510
|
if (!s.mailbox) s.mailbox = createMailbox({ me: s.name, startAt: s.hive.doc.getArray('chat').length })
|
|
511
|
+
catchUp(s)
|
|
414
512
|
return s.mailbox
|
|
415
513
|
}
|
|
514
|
+
// THE LISTENER READS MAIL TOO (3 Oct 2026). An agent on Claude Code or Antigravity is woken by
|
|
515
|
+
// hivecode-await, a separate process with a cursor of its own, so every message it delivered was
|
|
516
|
+
// still unread here: the next tool call announced "UNREAD MAIL: N ... Handle before continuing"
|
|
517
|
+
// about mail the agent had already acted on (19 of them in one session), and an agent that obeys
|
|
518
|
+
// redoes old instructions. Both now meet at the room's per-member cursor, which only moves forward:
|
|
519
|
+
// whichever of them handed a message over, the other knows. A cursor past the end of chat belongs to
|
|
520
|
+
// a rebuilt room and is ignored, as the mailbox itself does. Every peek and take goes through here.
|
|
521
|
+
function catchUp(s) {
|
|
522
|
+
if (!s || !s.mailbox) return
|
|
523
|
+
try {
|
|
524
|
+
const c = RC.cursorOf(s.hive.doc.getMap('cursors'), s.name)
|
|
525
|
+
const at = (c && Number(c.at)) || 0
|
|
526
|
+
if (at > s.mailbox.cursor && at <= s.hive.doc.getArray('chat').length) s.mailbox.seek(at)
|
|
527
|
+
} catch { /* a room that predates receipts */ }
|
|
528
|
+
}
|
|
416
529
|
function chatArray() { return requireSession().hive.doc.getArray('chat').toArray() }
|
|
417
530
|
|
|
418
531
|
// HIV-P11. Returns null rather than throwing when there is no session: a liveness stamp is an
|
|
@@ -539,61 +652,116 @@ function waitForWork(timeoutMs, opts = {}) {
|
|
|
539
652
|
})
|
|
540
653
|
}
|
|
541
654
|
|
|
655
|
+
// SAID ONCE, THEN SHORT (27 Sep 2026). An explanation is worth its tokens the first time an agent
|
|
656
|
+
// meets a rule in a session and nothing after that: results stay in the transcript, so a paragraph
|
|
657
|
+
// repeated on every call is paid again on every later turn. The warning itself always stays.
|
|
658
|
+
const explained = new Set()
|
|
659
|
+
const firstTime = (key) => (explained.has(key) ? false : (explained.add(key), true))
|
|
660
|
+
|
|
661
|
+
// A ROOM CHANGE THAT COULD NOT LAND HERE (3 Oct 2026). sync.js tells the room when a file in this
|
|
662
|
+
// folder is read-only or locked and so stays behind the room's copy, but that line goes out in this
|
|
663
|
+
// agent's own name, and an agent is never handed its own lines: the one participant working from
|
|
664
|
+
// the stale copy was the one nobody told. Said once per file, on a call the agent was making anyway.
|
|
665
|
+
function unwritableNotice(s) {
|
|
666
|
+
try {
|
|
667
|
+
const fresh = (s.hive.unwritable ? s.hive.unwritable() : []).filter((u) => firstTime('unwritable:' + u.path))
|
|
668
|
+
if (!fresh.length) return ''
|
|
669
|
+
const shown = fresh.slice(0, 5)
|
|
670
|
+
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` +
|
|
671
|
+
shown.map((u) => ` - ${u.path} (${u.code})`).join('\n') +
|
|
672
|
+
(fresh.length > shown.length ? `\n ... (+${fresh.length - shown.length} more; hive_status lists all)` : '') +
|
|
673
|
+
`\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.`
|
|
674
|
+
} catch { return '' }
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// TWO ROOM FILES, ONE FILE ON THIS DISK (3 Oct 2026; sync.js checkCaseCollision). Case.js and case.js
|
|
678
|
+
// can both be in the room, but a Windows or Mac disk holds only one of them, so the other is not in
|
|
679
|
+
// this folder. The room is told in this agent's own name; this is where the agent is told.
|
|
680
|
+
function collidedNotice(s) {
|
|
681
|
+
try {
|
|
682
|
+
const fresh = (s.hive.collided ? s.hive.collided() : []).filter((c) => firstTime('collided:' + c.path))
|
|
683
|
+
if (!fresh.length) return ''
|
|
684
|
+
const shown = fresh.slice(0, 5)
|
|
685
|
+
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` +
|
|
686
|
+
shown.map((c) => ` - ${c.path} (your folder has ${c.with})`).join('\n') +
|
|
687
|
+
(fresh.length > shown.length ? `\n ... (+${fresh.length - shown.length} more; hive_status lists all)` : '') +
|
|
688
|
+
`\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.`
|
|
689
|
+
} catch { return '' }
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// A KEY IN A FILE KEEPS THE FILE HERE (3 Oct 2026; sync.js heldForSecret). The agent that pasted it
|
|
693
|
+
// is usually the one that needs telling, and the room line about it is in that agent's own name.
|
|
694
|
+
function withheldNotice(s) {
|
|
695
|
+
try {
|
|
696
|
+
const fresh = (s.hive.withheld ? s.hive.withheld() : []).filter((w) => firstTime('withheld:' + w.path))
|
|
697
|
+
if (!fresh.length) return ''
|
|
698
|
+
const shown = fresh.slice(0, 5)
|
|
699
|
+
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` +
|
|
700
|
+
shown.map((w) => ` - ${w.path}: ${/^(?:[aeiou]|npm\b)/i.test(w.kind) ? 'an' : 'a'} ${w.kind} on line ${w.line}`).join('\n') +
|
|
701
|
+
(fresh.length > shown.length ? `\n ... (+${fresh.length - shown.length} more; hive_status lists all)` : '') +
|
|
702
|
+
`\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.`
|
|
703
|
+
} catch { return '' }
|
|
704
|
+
}
|
|
705
|
+
|
|
542
706
|
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: '
|
|
707
|
+
{ 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.',
|
|
708
|
+
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)' } } } },
|
|
709
|
+
{ 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.',
|
|
710
|
+
inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name' }, text: { type: 'string', description: 'the task' } }, required: ['to', 'text'] } },
|
|
711
|
+
{ name: 'hive_read_tasks', description: 'Tasks aimed at you (act only on accepted ones) and all pending tasks awaiting approval.',
|
|
712
|
+
inputSchema: { type: 'object', properties: {} } },
|
|
713
|
+
{ 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.',
|
|
714
|
+
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'] } } } },
|
|
715
|
+
{ 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.',
|
|
716
|
+
inputSchema: { type: 'object', properties: { peek: { type: 'boolean', description: 'true = look without marking messages read' } } } },
|
|
717
|
+
{ name: 'hive_approve', description: 'Approve a pending task for an AI you own; it may then act on it.',
|
|
718
|
+
inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'task id' } }, required: ['id'] } },
|
|
719
|
+
{ name: 'hive_deny', description: 'Deny a pending task for an AI you own; it will not act on it.',
|
|
720
|
+
inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'task id' } }, required: ['id'] } },
|
|
721
|
+
{ name: 'hive_complete', description: 'Mark a task you were doing as done, with an optional note.',
|
|
722
|
+
inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'task id' }, note: { type: 'string', description: 'what was done' } }, required: ['id'] } },
|
|
723
|
+
{ 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.',
|
|
724
|
+
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'] } },
|
|
725
|
+
{ 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.',
|
|
726
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'recent addressed messages to check (default 10)' } } } },
|
|
727
|
+
{ 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.',
|
|
728
|
+
inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'the obligation id' }, from: { type: 'string', description: 'close every question you asked this agent' } } } },
|
|
729
|
+
{ 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.',
|
|
730
|
+
inputSchema: { type: 'object', properties: { all: { type: 'boolean', description: 'true = the whole room' } } } },
|
|
731
|
+
{ 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.',
|
|
732
|
+
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' } } } },
|
|
733
|
+
{ name: 'hive_read_chat', description: 'Read recent room chat: what people and agents are doing and saying.',
|
|
734
|
+
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)' } } } },
|
|
735
|
+
{ 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.',
|
|
548
736
|
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: { id: { type: 'string' } }, required: ['id'] } },
|
|
555
|
-
{ name: 'hive_deny', description: 'Deny a pending task (you must be the owner of the target AI).',
|
|
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.',
|
|
737
|
+
{ 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.',
|
|
738
|
+
inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'file path or region id' }, intent: { type: 'string', description: 'what you intend to do' } }, required: ['region'] } },
|
|
739
|
+
{ 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.',
|
|
740
|
+
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'] } },
|
|
741
|
+
{ name: 'hive_claims', description: 'The live claim board: who holds which file. Authoritative; read it instead of asking the room.',
|
|
572
742
|
inputSchema: { type: 'object', properties: {} } },
|
|
573
|
-
{ name: '
|
|
574
|
-
inputSchema: { type: 'object', properties: {
|
|
575
|
-
{ name: '
|
|
576
|
-
inputSchema: { type: 'object', properties: {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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.',
|
|
743
|
+
{ 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.',
|
|
744
|
+
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'] } },
|
|
745
|
+
{ 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.',
|
|
746
|
+
inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'the forked file path, as announced in chat' } }, required: ['file'] } },
|
|
747
|
+
{ 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.',
|
|
748
|
+
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'] } },
|
|
749
|
+
{ name: 'hive_members', description: 'List who is currently in the room (humans and AI agents).',
|
|
582
750
|
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: '
|
|
596
|
-
|
|
751
|
+
{ name: 'hive_status', description: 'Show the current session (room, relay, folder, name).',
|
|
752
|
+
inputSchema: { type: 'object', properties: {} } },
|
|
753
|
+
{ name: 'hive_leave', description: 'Leave the room and stop syncing.',
|
|
754
|
+
inputSchema: { type: 'object', properties: {} } },
|
|
755
|
+
{ 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.',
|
|
756
|
+
inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'flag name' }, val: { type: 'string', description: 'short value' } }, required: ['key', 'val'] } },
|
|
757
|
+
{ name: 'hive_read_state', description: 'Read the global project state flags.',
|
|
758
|
+
inputSchema: { type: 'object', properties: {} } },
|
|
759
|
+
{ 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"}}}',
|
|
760
|
+
inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'state key' }, rules: { type: 'object', description: 'the JSON rules' } }, required: ['key', 'rules'] } },
|
|
761
|
+
{ 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.',
|
|
762
|
+
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'] } },
|
|
763
|
+
{ 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.',
|
|
764
|
+
inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'workspace-relative path' } }, required: ['file'] } },
|
|
597
765
|
]
|
|
598
766
|
|
|
599
767
|
// The state machine's enforcement point. Three separate gates, and each one exists because a rule
|
|
@@ -643,7 +811,7 @@ function enforceDcoLock(opts = {}) {
|
|
|
643
811
|
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
812
|
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
813
|
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
|
|
814
|
+
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
815
|
FROZEN: `[Path Frozen] ${opts.path} is frozen by the state machine. Ask the owner to set path:${opts.path}=LIVE before writing it.`,
|
|
648
816
|
}
|
|
649
817
|
if (ps && stop[ps]) throw new Error(stop[ps])
|
|
@@ -699,14 +867,15 @@ function enforceDcoLock(opts = {}) {
|
|
|
699
867
|
// matters most here: an 0.10.0 client in a room with an 0.11.1 one is invisible in the audit
|
|
700
868
|
// log while its roommate is named, so the room's own membership record disagrees with itself.
|
|
701
869
|
// Also carries the ICR fix that stopped a whole-file rewrite duplicating every declaration.
|
|
702
|
-
// VERSION is load-bearing, not cosmetic:
|
|
703
|
-
//
|
|
704
|
-
//
|
|
870
|
+
// VERSION is load-bearing, not cosmetic: every client reports it in presence (hv), and the room
|
|
871
|
+
// flags a MIXED BUILD from it (sync.js checkBuilds; the join reply names one to the joiner), because
|
|
872
|
+
// two builds in one room can lose file content. Until 2 Oct the join message also told every agent
|
|
873
|
+
// to announce it in chat. Hardcoding it meant that the moment package.json was
|
|
705
874
|
// bumped the two disagreed silently -- so the one mechanism for detecting a mixed build was
|
|
706
875
|
// itself reporting the wrong build. Read the truth instead; package.json always ships in the
|
|
707
876
|
// tarball and sits beside this file in both the repo and the published package.
|
|
708
877
|
// A failed read must never stop the server from starting, so it falls back rather than throwing.
|
|
709
|
-
import { VERSION } from "./hive-version.js"
|
|
878
|
+
import { VERSION, BUILD, VERSION_LABEL } from "./hive-version.js"
|
|
710
879
|
export { VERSION }
|
|
711
880
|
// `instructions` is placed in the agent's SYSTEM PROMPT by the client at connect time, which
|
|
712
881
|
// makes it the only channel that reaches an agent before its first tool call. Everything else we
|
|
@@ -749,10 +918,15 @@ async function dispatch(req) {
|
|
|
749
918
|
// literal sense: the bytes are in a local Yjs doc, the relay has never seen them, and the
|
|
750
919
|
// word the agent reads is the same word it reads on success. status().connected was
|
|
751
920
|
// right there and free the whole time.
|
|
752
|
-
const sendConn = (() => { try { return requireSession().hive
|
|
921
|
+
const sendConn = (() => { try { return connectionOf(requireSession().hive) } catch { return null } })()
|
|
753
922
|
const sendWarn = RC.sendWarning({ connected: !sendConn || !!sendConn.connected })
|
|
754
923
|
const stamp = (t) => (sendWarn ? `${sendWarn}\n\n${t}` : t)
|
|
755
924
|
const raw = String(args.text || '')
|
|
925
|
+
// A KEY IS NOT A MESSAGE (3 Oct 2026). The room's chat goes to the relay and to everyone in
|
|
926
|
+
// it, for good: a message cannot be unsent. The same formats that keep a file at home
|
|
927
|
+
// (hive-secrets.js) keep a message unsent, whoever asked for the key.
|
|
928
|
+
const chatKey = findSecrets(raw, { tokens: true })[0]
|
|
929
|
+
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
930
|
// The marker is in the text itself so it survives into HIVE_CHAT.md and is visible to
|
|
757
931
|
// a human reading the log, not just to the recipient's tool output.
|
|
758
932
|
const body = args.urgent && !/^\s*\[!\]/.test(raw) ? `[!] ${raw}` : raw
|
|
@@ -762,12 +936,6 @@ async function dispatch(req) {
|
|
|
762
936
|
// reported as landing in everyone's ambient digest, where it is meant to be skipped.
|
|
763
937
|
// Immediate, per-call feedback, at the moment the mistake is made.
|
|
764
938
|
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
939
|
// WHO IS ACTUALLY GOING TO READ THIS. The sender is the only participant who can act on
|
|
772
940
|
// "they left" or "they are mid-task", and it is worth strictly more here than in a
|
|
773
941
|
// reply: it arrives before the sender starts waiting, and it costs the room no message.
|
|
@@ -787,6 +955,28 @@ async function dispatch(req) {
|
|
|
787
955
|
if (m && m.by) everHere.add(String(m.by).toLowerCase())
|
|
788
956
|
}
|
|
789
957
|
|
|
958
|
+
// 5.2: Route only to known names, @all, explicit reply requests (needsReply),
|
|
959
|
+
// or unknown @words that are direct addresses (at start) or typos (within edit distance <= 2 of a known name).
|
|
960
|
+
// Unrecognized @words in prose are ignored.
|
|
961
|
+
const allKnown = new Set([...present, ...everHere])
|
|
962
|
+
function isAddressee(n) {
|
|
963
|
+
const lc = n.toLowerCase()
|
|
964
|
+
if (lc === 'all') return true
|
|
965
|
+
if (allKnown.has(lc)) return true
|
|
966
|
+
if (args.needsReply) return true
|
|
967
|
+
if (isDirectAddress(body, n)) return true
|
|
968
|
+
if (findClosestKnown(n, [...allKnown])) return true
|
|
969
|
+
return false
|
|
970
|
+
}
|
|
971
|
+
const addressed = named.filter(isAddressee)
|
|
972
|
+
|
|
973
|
+
if (!addressed.length && !mentionsAll(body)) {
|
|
974
|
+
return text(stamp(`sent: ${body}`) + `\n\nNOBODY WAS ADDRESSED. This went to every agent's "room ` +
|
|
975
|
+
`activity", where they are told to skip it — so assume no one acts on it. Name the ` +
|
|
976
|
+
`agents you actually need with @Name (hive_members lists who is here). Use an ` +
|
|
977
|
+
`unaddressed message only for a genuine announcement to the whole room.`)
|
|
978
|
+
}
|
|
979
|
+
|
|
790
980
|
// HIV-P11b. Send-time inspection of recipient state:
|
|
791
981
|
// Warns when addressing PARKED, PAUSED, PASSIVE, or GONE/AWAY agents.
|
|
792
982
|
function describeRecipientLiveness(n) {
|
|
@@ -801,6 +991,8 @@ async function dispatch(req) {
|
|
|
801
991
|
present: isPresent,
|
|
802
992
|
paused: isPaused,
|
|
803
993
|
passive: isPassive,
|
|
994
|
+
listener: !!(mem && mem.listener),
|
|
995
|
+
app: mem && mem.app,
|
|
804
996
|
now,
|
|
805
997
|
parkedAfter,
|
|
806
998
|
})
|
|
@@ -822,10 +1014,26 @@ async function dispatch(req) {
|
|
|
822
1014
|
if (everHere.has(lc)) {
|
|
823
1015
|
return `⚠ @${displayName} — AWAY RIGHT NOW. Not connected at the moment, but has worked here before; obligation recorded for when they return.`
|
|
824
1016
|
} else {
|
|
825
|
-
|
|
1017
|
+
// 5.2 Typo catch: within distance 2 of known names
|
|
1018
|
+
const closest = findClosestKnown(n, [...allKnown])
|
|
1019
|
+
if (closest && closest.name.toLowerCase() !== lc) {
|
|
1020
|
+
return `⚠ @${displayName} — not in this room (did you mean @${closest.name}?).`
|
|
1021
|
+
}
|
|
1022
|
+
// 5.2 Direct address position at start of message or explicit needsReply
|
|
1023
|
+
if (isDirectAddress(body, n) || args.needsReply) {
|
|
1024
|
+
// 5.3 Terse 1-liner on subsequent mentions
|
|
1025
|
+
if (firstTime('notInRoom:' + lc)) {
|
|
1026
|
+
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.`
|
|
1027
|
+
}
|
|
1028
|
+
return `⚠ @${displayName} — not in this room.`
|
|
1029
|
+
}
|
|
1030
|
+
return ''
|
|
826
1031
|
}
|
|
827
1032
|
}
|
|
828
|
-
|
|
1033
|
+
if (d.state === ACT.WAITING && !d.reachable) {
|
|
1034
|
+
return `⚠ @${displayName} — listener running, but this app is not woken by it.`
|
|
1035
|
+
}
|
|
1036
|
+
// d.state is WORKING or WAITING (reachable)
|
|
829
1037
|
const busy = findBusy(busyMap, n)
|
|
830
1038
|
if (busy) {
|
|
831
1039
|
return describeRecipient(displayName, busy, true, now)
|
|
@@ -833,7 +1041,7 @@ async function dispatch(req) {
|
|
|
833
1041
|
return ''
|
|
834
1042
|
}
|
|
835
1043
|
|
|
836
|
-
const candidateNames =
|
|
1044
|
+
const candidateNames = addressed.filter((n) => n.toLowerCase() !== 'all' && n.toLowerCase() !== s.name.toLowerCase())
|
|
837
1045
|
const seenCandidates = new Set()
|
|
838
1046
|
const uniqueCandidates = []
|
|
839
1047
|
for (const n of candidateNames) {
|
|
@@ -844,7 +1052,7 @@ async function dispatch(req) {
|
|
|
844
1052
|
}
|
|
845
1053
|
}
|
|
846
1054
|
const notes = uniqueCandidates.map(describeRecipientLiveness).filter(Boolean)
|
|
847
|
-
const to = mentionsAll(body) ? 'the whole room (@all)' :
|
|
1055
|
+
const to = mentionsAll(body) ? 'the whole room (@all)' : addressed.map((n) => '@' + n).join(', ')
|
|
848
1056
|
// NEEDS REPLY. A question is recorded, not just spoken. One obligation per agent named.
|
|
849
1057
|
// HIV-P6: If the room knows nobody will answer, DO NOT record a debt.
|
|
850
1058
|
// 1. Absent names: warned immediately, ZERO obligations opened.
|
|
@@ -900,13 +1108,17 @@ async function dispatch(req) {
|
|
|
900
1108
|
const opened = [...openedActive, ...openedAway]
|
|
901
1109
|
|
|
902
1110
|
if (opened.length) {
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1111
|
+
const ids = opened.map((o) => `${o.id} (${o.to})`).join(', ')
|
|
1112
|
+
obStr += firstTime('obligation')
|
|
1113
|
+
? `\n\nYOU ARE OWED AN ANSWER — ${ids}\n` +
|
|
1114
|
+
`This stays open until YOU call hive_answered. The agent you asked cannot close it, and ` +
|
|
1115
|
+
`a reply does not close it either — you decide whether you were actually answered. If it ` +
|
|
1116
|
+
`is still open in ${Math.round(dueMs / 60000)} min you will be reminded on your next hive_wait.`
|
|
1117
|
+
: `\n\nYOU ARE OWED AN ANSWER — ${ids}. Close it with hive_answered once answered; reminder in ${Math.round(dueMs / 60000)} min.`
|
|
907
1118
|
}
|
|
908
1119
|
}
|
|
909
|
-
|
|
1120
|
+
// The sender wrote it: echo enough to prove what went out, not all of it again.
|
|
1121
|
+
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
1122
|
}
|
|
911
1123
|
case 'hive_busy': {
|
|
912
1124
|
const s = requireSession()
|
|
@@ -938,7 +1150,17 @@ async function dispatch(req) {
|
|
|
938
1150
|
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
1151
|
}
|
|
940
1152
|
const n = args.limit || 30
|
|
941
|
-
|
|
1153
|
+
// OTHER PEOPLE'S LONG MESSAGES, SHORTENED (27 Sep 2026). Catching up on 30 messages in a room
|
|
1154
|
+
// of multi-paragraph agent reports is tens of thousands of characters, and a result is paid
|
|
1155
|
+
// again on every later turn. Messages addressed to you stay whole; the rest are cut at 280
|
|
1156
|
+
// characters with the length shown; identical runs collapse (x56 conflict storms); full:true
|
|
1157
|
+
// returns everything.
|
|
1158
|
+
const CUT = 280
|
|
1159
|
+
const msgs = collapseRepeats(all.slice(-n)).map((m) => {
|
|
1160
|
+
const t = String(m.text || '')
|
|
1161
|
+
const cut = !args.full && t.length > CUT && !mentionsName(t, me)
|
|
1162
|
+
return formatMessage(cut ? { ...m, text: t.slice(0, CUT) + ` …(+${t.length - CUT} chars; full:true shows it)` } : m)
|
|
1163
|
+
})
|
|
942
1164
|
return text(msgs.join('\n') || '(no messages yet)')
|
|
943
1165
|
}
|
|
944
1166
|
case 'hive_check': {
|
|
@@ -955,9 +1177,9 @@ async function dispatch(req) {
|
|
|
955
1177
|
// Derived from the log, so it needs no state and cannot drift.
|
|
956
1178
|
const owed = unansweredFor(all, s.name)
|
|
957
1179
|
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.`
|
|
1180
|
+
? `\n\n⏳ STILL WAITING ON A REPLY FROM YOU: ${owed.map((o) => '@' + o.from).join(', ')}.` +
|
|
1181
|
+
(firstTime('owed') ? ` Answer with hive_say (name them), even if the answer is "not yet, I am on X" — ` +
|
|
1182
|
+
`silence is the one thing they cannot act on.` : '')
|
|
961
1183
|
: ''
|
|
962
1184
|
if (!got.unreadCount && !tasks.length) {
|
|
963
1185
|
return text(owed.length
|
|
@@ -996,10 +1218,24 @@ async function dispatch(req) {
|
|
|
996
1218
|
const dig = ambientDigest(ambient)
|
|
997
1219
|
if (dig) parts.push(dig)
|
|
998
1220
|
if (tasks.length) parts.push(`approved work queued for you:\n${tasks.map(fmtTask).join('\n')}`)
|
|
999
|
-
|
|
1221
|
+
let unsharedPart = ''
|
|
1222
|
+
try {
|
|
1223
|
+
const list = s.hive.unshared ? s.hive.unshared() : []
|
|
1224
|
+
if (list.length) {
|
|
1225
|
+
const unannounced = list.filter((u) => firstTime('unshared:' + u.path))
|
|
1226
|
+
if (unannounced.length) {
|
|
1227
|
+
const shown = unannounced.slice(0, 5)
|
|
1228
|
+
const extra = unannounced.length - shown.length
|
|
1229
|
+
unsharedPart = `\n\n⛔ ${unannounced.length} unshared file(s) on disk (cannot sync to room):\n` +
|
|
1230
|
+
shown.map((u) => ` - ${u.path} (${u.reason}): stays local; share another way (commit it)`).join('\n') +
|
|
1231
|
+
(extra > 0 ? `\n ... (+${extra} more; check hive_status for all)` : '')
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
} catch { }
|
|
1235
|
+
return text(parts.join('\n\n') + owedLine + unsharedPart + unwritableNotice(s) + withheldNotice(s) + collidedNotice(s))
|
|
1000
1236
|
}
|
|
1001
1237
|
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') }
|
|
1238
|
+
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
1239
|
case 'hive_read_tasks': {
|
|
1004
1240
|
const all = taskList(); const me = requireSession().name
|
|
1005
1241
|
const mine = all.filter((t) => t.to === me)
|
|
@@ -1007,7 +1243,8 @@ async function dispatch(req) {
|
|
|
1007
1243
|
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
1244
|
}
|
|
1009
1245
|
case 'hive_wait': {
|
|
1010
|
-
const
|
|
1246
|
+
const wp = waitPolicy()
|
|
1247
|
+
const ms = Math.min(Math.max(Number(args.timeoutSeconds) || wp.def, 1), wp.max) * 1000
|
|
1011
1248
|
// HIV-P11. `waitingSince` means "inside this call right now", never "waited at some
|
|
1012
1249
|
// point", and the finally is what keeps that true. A wait that returns or throws while
|
|
1013
1250
|
// leaving the flag set describes an agent parked in a call it has already left -- and
|
|
@@ -1031,11 +1268,10 @@ async function dispatch(req) {
|
|
|
1031
1268
|
// time AND enough output have passed that it is probably no longer in context.
|
|
1032
1269
|
const stateBody = Object.keys(globalState).length ? `\n[GLOBAL PROJECT STATE]\n${JSON.stringify(globalState, null, 2)}\n` : ''
|
|
1033
1270
|
const stateStr = stateBody && throttle.send('state', stateBody) ? stateBody : ''
|
|
1034
|
-
// The
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1038
|
-
// would be one more description to drift out of date.
|
|
1271
|
+
// The rules went the other way: hive_join hands over their core ONCE (JOIN_RULES; until 27 Sep
|
|
1272
|
+
// 2026 it pasted all 6 KB of HIVE_RULES.md), and compaction reaches that first. When it has
|
|
1273
|
+
// probably been compacted away, a pointer is re-sent, never a copy: HIVE_RULES.md is on this
|
|
1274
|
+
// agent's own disk, and a second summary here would be one more description to drift.
|
|
1039
1275
|
const rulesStr = throttle.should('rules', RULES_NUDGE).why === 'likely-compacted'
|
|
1040
1276
|
? (throttle.sent('rules', RULES_NUDGE), `\n${RULES_NUDGE}\n`)
|
|
1041
1277
|
: ''
|
|
@@ -1046,9 +1282,13 @@ async function dispatch(req) {
|
|
|
1046
1282
|
// provider reconnects on its own with backoff, so the correct action is to do
|
|
1047
1283
|
// nothing; the agent just has to be told that, in the one place it is looking.
|
|
1048
1284
|
const conn = (() => {
|
|
1049
|
-
try { return requireSession().hive
|
|
1285
|
+
try { return connectionOf(requireSession().hive) } catch { return null }
|
|
1050
1286
|
})()
|
|
1051
|
-
|
|
1287
|
+
// A lost folder outranks the connection line, and replaces it: that line says never to
|
|
1288
|
+
// rejoin, and a rejoin is the one way back for a folder that is gone.
|
|
1289
|
+
const connStr = (conn && conn.folderLost)
|
|
1290
|
+
? `\n[FOLDER LOST] ${conn.folderLost} Tell your person.\n`
|
|
1291
|
+
: (conn && !conn.connected)
|
|
1052
1292
|
? `\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
1293
|
: ''
|
|
1054
1294
|
|
|
@@ -1091,17 +1331,25 @@ async function dispatch(req) {
|
|
|
1091
1331
|
}
|
|
1092
1332
|
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
1333
|
}
|
|
1094
|
-
if (!r)
|
|
1334
|
+
if (!r) {
|
|
1335
|
+
const sess = requireSession()
|
|
1336
|
+
const armed = isArmed(sess)
|
|
1337
|
+
const ll = !armed ? listenLine(sess) : ''
|
|
1338
|
+
const waitMsg = armed
|
|
1339
|
+
? '(nothing yet — your background listener is running and will wake you when called: you may end your turn now.)'
|
|
1340
|
+
: '(nothing yet — no approved work or new messages in the wait window. Call hive_wait again to keep waiting.)'
|
|
1341
|
+
return text(`${waitMsg}${owedStr}${connStr}${unwritableNotice(sess)}${withheldNotice(sess)}${collidedNotice(sess)}${stateStr}${rulesStr}${ll}`)
|
|
1342
|
+
}
|
|
1095
1343
|
const parts = []
|
|
1096
1344
|
if (r.resumed) parts.push('▶ You were RESUMED by mission control — continue.')
|
|
1097
1345
|
const mb = mentionBlock(r); if (mb) parts.push(mb)
|
|
1098
1346
|
if (r.tasks.length) parts.push(`APPROVED WORK — do these now, then hive_complete each:\n${r.tasks.map(fmtTask).join('\n')}`)
|
|
1099
1347
|
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.)'))
|
|
1348
|
+
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
1349
|
}
|
|
1102
1350
|
case 'hive_delivered': {
|
|
1103
1351
|
const s2 = requireSession()
|
|
1104
|
-
const conn = (() => { try { return s2.hive
|
|
1352
|
+
const conn = (() => { try { return connectionOf(s2.hive) } catch { return null } })()
|
|
1105
1353
|
const connected = !conn || !!conn.connected
|
|
1106
1354
|
const here = new Set(s2.hive.members().map((m) => String(m.name).toLowerCase()))
|
|
1107
1355
|
const rs = RC.receipts({
|
|
@@ -1160,6 +1408,8 @@ async function dispatch(req) {
|
|
|
1160
1408
|
present: !!mem,
|
|
1161
1409
|
paused: !!(c && c.state === 'paused'),
|
|
1162
1410
|
passive: !!(mem && mem.passive),
|
|
1411
|
+
listener: !!(mem && mem.listener),
|
|
1412
|
+
app: mem && mem.app,
|
|
1163
1413
|
now,
|
|
1164
1414
|
parkedAfter,
|
|
1165
1415
|
})
|
|
@@ -1173,6 +1423,9 @@ async function dispatch(req) {
|
|
|
1173
1423
|
if (d.state === ACT.PAUSED) {
|
|
1174
1424
|
return `${base} [PAUSED by human — do not reassign]`
|
|
1175
1425
|
}
|
|
1426
|
+
if (d.state === ACT.WAITING && !d.reachable) {
|
|
1427
|
+
return `${base} [UNREACHABLE — @${mem ? mem.name : o.to}: listener running, but this app is not woken by it]`
|
|
1428
|
+
}
|
|
1176
1429
|
return base
|
|
1177
1430
|
}
|
|
1178
1431
|
|
|
@@ -1196,11 +1449,24 @@ async function dispatch(req) {
|
|
|
1196
1449
|
const f = String(args.file || ''); if (!f) return err('need a file path')
|
|
1197
1450
|
if (!Number.isFinite(Number(args.bytes))) return err('need bytes — measure your own folder, do not estimate')
|
|
1198
1451
|
const r = requireSession().hive.attest(f, Number(args.bytes), Number(args.lines || 0))
|
|
1199
|
-
|
|
1452
|
+
if (r.error) return err(r.error)
|
|
1453
|
+
const who = Object.entries(r.peers).map(([n, v]) => {
|
|
1454
|
+
if (v.stale) return `${n}: ${v.bytes} bytes / ${v.lines} lines (${v.staleReason || 'stale'})`
|
|
1455
|
+
if (v.absent) return `${n}: absent (not in folder)`
|
|
1456
|
+
const extra = v.note || ''
|
|
1457
|
+
return `${n}: ${v.bytes} bytes / ${v.lines} lines${extra}`
|
|
1458
|
+
}).join('\n ')
|
|
1459
|
+
const mismatchNote = r.mismatch ? `\n(note: you stated ${r.mismatch.stated}, your folder holds ${r.mismatch.measured})` : ''
|
|
1460
|
+
const lagNotice = (r.lagging && r.lagNotice) ? `\n(note: ${r.lagNotice}, re-attest once disk syncs)` : ''
|
|
1200
1461
|
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
|
|
1462
|
+
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
1463
|
}
|
|
1203
|
-
|
|
1464
|
+
const quorumStatus = r.quorumOnOlder
|
|
1465
|
+
? 'QUORUM on an OLDER version: the room\'s copy changed since, re-attest.'
|
|
1466
|
+
: r.quorum
|
|
1467
|
+
? 'QUORUM: 2+ peers agree — this publish is confirmed.'
|
|
1468
|
+
: 'Only you so far. Ask another agent to hive_attest the same path; a single folder proves nothing.'
|
|
1469
|
+
return text(`attested ${f} at ${r.measuredBytes ?? args.bytes} bytes.\n ${who}${mismatchNote}${lagNotice}\n${quorumStatus}`)
|
|
1204
1470
|
}
|
|
1205
1471
|
case 'hive_verified': {
|
|
1206
1472
|
const f = String(args.file || ''); if (!f) return err('need a file path')
|
|
@@ -1208,14 +1474,19 @@ async function dispatch(req) {
|
|
|
1208
1474
|
// Attestation and gate verdicts are independent records, so an unattested path must still
|
|
1209
1475
|
// report its gate. Returning early here would hide a FAILED check behind "nobody measured
|
|
1210
1476
|
// the bytes", which is the opposite of what this tool is for.
|
|
1211
|
-
if (!r.
|
|
1477
|
+
if (!r.totalCount) {
|
|
1212
1478
|
const g0 = requireSession().hive.gateVerdicts(f)
|
|
1213
1479
|
const extra = Object.entries(g0).map(([n, v]) => v.forced
|
|
1214
1480
|
? `\n [!] ${n} FORCE-released it without passing the gate${v.why ? ` — "${v.why}"` : ''}`
|
|
1215
1481
|
: `\n ${v.pass ? '[ok]' : '[!]'} ${n}'s gate ${v.pass ? 'passed' : `FAILED (exit ${v.exit})`}`).join('')
|
|
1216
1482
|
return text(`${f}: NOT attested by anyone. Unverified — measure it and call hive_attest.${extra}`)
|
|
1217
1483
|
}
|
|
1218
|
-
const who = Object.entries(r.peers).map(([n, v]) =>
|
|
1484
|
+
const who = Object.entries(r.peers).map(([n, v]) => {
|
|
1485
|
+
if (v.stale) return `${n}: ${v.bytes} bytes / ${v.lines} lines (${v.staleReason || 'stale'})`
|
|
1486
|
+
if (v.absent) return `${n}: absent (not in folder)`
|
|
1487
|
+
const extra = v.note || ''
|
|
1488
|
+
return `${n}: ${v.bytes} bytes / ${v.lines} lines${extra} at ${v.at}`
|
|
1489
|
+
}).join('\n ')
|
|
1219
1490
|
// Same bytes in two folders says the file PROPAGATED. It says nothing about whether it
|
|
1220
1491
|
// WORKS, which is the actual question an agent is asking here — so the gate verdicts
|
|
1221
1492
|
// belong in this answer. A file two peers agree on and whose owner's tests failed is the
|
|
@@ -1227,11 +1498,18 @@ async function dispatch(req) {
|
|
|
1227
1498
|
const gate = gLines.length
|
|
1228
1499
|
? `\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
1500
|
: '\n\nNo gate verdict recorded for this path — nobody has run a check on it here.'
|
|
1230
|
-
|
|
1501
|
+
const statusLine = r.diverged
|
|
1502
|
+
? '[!] DIVERGED — peers hold different content. Do not write or build on this path.'
|
|
1503
|
+
: r.quorumOnOlder
|
|
1504
|
+
? 'QUORUM on an OLDER version: the room\'s copy changed since, re-attest.'
|
|
1505
|
+
: r.quorum
|
|
1506
|
+
? 'QUORUM: confirmed by 2+ peers at the same size.'
|
|
1507
|
+
: 'Attested by ONE peer only — not yet proof.'
|
|
1508
|
+
return text(`${f}\n ${who}\n\n${statusLine}${gate}`)
|
|
1231
1509
|
}
|
|
1232
1510
|
case 'hive_approve': { const r = requireSession().hive.decide(String(args.id), true); return r.ok ? text(`approved ${args.id}`) : err(r.error) }
|
|
1233
1511
|
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) }
|
|
1512
|
+
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
1513
|
case 'hive_claim': {
|
|
1236
1514
|
const region = String(args.region || ''); if (!region) return err('need a region (file path)')
|
|
1237
1515
|
// Claiming does not spend a lease — it is not a write. But claiming a poisoned path is
|
|
@@ -1269,11 +1547,12 @@ Nothing was claimed. Put the path inside the room's sync scope, or pick one that
|
|
|
1269
1547
|
try { fetched = await requireSession().hive.fetch(region) } catch { }
|
|
1270
1548
|
const head = `claimed ${region} — it's yours.`
|
|
1271
1549
|
if (fetched && fetched.notInRoom) {
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1550
|
+
// Explained once per session: an agent starting a new project claims one new file after
|
|
1551
|
+
// another, and the paragraph cost ~120 tokens each time. The warning itself stays.
|
|
1552
|
+
const why = firstTime('notInRoom')
|
|
1553
|
+
? `!! ${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.`
|
|
1554
|
+
: `!! ${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.`
|
|
1555
|
+
return text(`${head}\n\n${why}\n\nEdit it, then call hive_release.`)
|
|
1277
1556
|
}
|
|
1278
1557
|
if (fetched && fetched.ok === false) {
|
|
1279
1558
|
return text(`${head}
|
|
@@ -1318,6 +1597,10 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
|
|
|
1318
1597
|
}
|
|
1319
1598
|
const r = requireSession().hive.edit(editPath, String(args.content ?? ''), String(args.intent || ''))
|
|
1320
1599
|
if (r.error) return err(r.error)
|
|
1600
|
+
if (r.withheld) {
|
|
1601
|
+
firstTime('withheld:' + r.file) // said here, so the next hive_wait does not say it again
|
|
1602
|
+
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.`)
|
|
1603
|
+
}
|
|
1321
1604
|
return text(r.conflicted
|
|
1322
1605
|
? `wrote ${r.file}, but it now carries <<<<<<< conflict markers (a concurrent edit collided). Read the file and resolve before continuing.`
|
|
1323
1606
|
: `✓ wrote ${r.file} through the medium — your intent is signed into its provenance ledger and the change is syncing to every peer.`)
|
|
@@ -1352,8 +1635,10 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
|
|
|
1352
1635
|
const rec = act ? act.get(String(m.name).toLowerCase()) : null
|
|
1353
1636
|
const c = controls.get(m.name)
|
|
1354
1637
|
const editing = m.editing && now - m.editing.at < 15000 ? `\n editing ${m.editing.file}` : ''
|
|
1355
|
-
return ACT.line(`${m.name} (${m.kind})`, rec, {
|
|
1638
|
+
return ACT.line(`${m.name} (${m.kind})${m.hv ? ` on ${m.hv}` : ''}`, rec, {
|
|
1356
1639
|
present: true, passive: !!m.passive,
|
|
1640
|
+
listener: !!m.listener,
|
|
1641
|
+
app: m.app,
|
|
1357
1642
|
paused: !!(c && c.state === 'paused'),
|
|
1358
1643
|
// HIV-P11c. Without this the room said `Ganesh (human) — PARKED: ... anything you
|
|
1359
1644
|
// send it now will sit unread until a human prompts it` about the human.
|
|
@@ -1384,15 +1669,21 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
|
|
|
1384
1669
|
// agent asks here, and only one of them had an answer: "am I reaching anyone" was
|
|
1385
1670
|
// reportable and "where is my source going" was not. A bare relay URL is not an
|
|
1386
1671
|
// 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 })
|
|
1672
|
+
const pv = checkRelay({ relay: s.relay, env: process.env, dir: s.dir, fs, room: s.room })
|
|
1388
1673
|
return text({
|
|
1389
|
-
room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION, ...(updateNotice() ? { update: updateNotice() } : {}),
|
|
1674
|
+
room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION, ...(BUILD ? { build: BUILD } : {}), ...(updateNotice() ? { update: updateNotice() } : {}),
|
|
1675
|
+
...(clientName() ? { app: clientName() } : {}), // which agent app this is: support needs it, and so does waitPolicy
|
|
1390
1676
|
privacy: pv.headline,
|
|
1391
1677
|
...(pv.warn ? { privacyWarning: pv.warn } : {}),
|
|
1392
1678
|
...(pv.pin && pv.pin.pinned ? { relayPin: pv.pin.origins.join(', ') } : {}),
|
|
1393
1679
|
unread: mailbox().peek(chatArray()).unreadCount,
|
|
1394
1680
|
connected: conn ? conn.connected : null,
|
|
1395
1681
|
filesSynced: conn ? `${conn.filesSynced}/${conn.files}` : null,
|
|
1682
|
+
...(conn && conn.folderLost ? { folderLost: conn.folderLost } : {}),
|
|
1683
|
+
...(conn && conn.unsharedFiles && conn.unsharedFiles.length ? { unshared: conn.unsharedFiles } : {}),
|
|
1684
|
+
...(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' } } : {}),
|
|
1685
|
+
...(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' } } : {}),
|
|
1686
|
+
...(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
1687
|
})
|
|
1397
1688
|
}
|
|
1398
1689
|
// HIV-P11. A deliberate leave is the one departure the room KNOWS about, so the activity
|
|
@@ -1456,8 +1747,16 @@ let mailNotice = 0
|
|
|
1456
1747
|
function mailBanner() {
|
|
1457
1748
|
try {
|
|
1458
1749
|
if (!session || !session.mailbox) return ''
|
|
1750
|
+
catchUp(session)
|
|
1459
1751
|
const m = session.mailbox.peek(chatArray())
|
|
1460
|
-
|
|
1752
|
+
// EVERY notice says it supersedes the earlier ones: a context is append-only, so an old "you have
|
|
1753
|
+
// mail" can only be retired by a newer notice that says so. What is said once per session (see
|
|
1754
|
+
// firstTime) is the explanation in the body.
|
|
1755
|
+
const first = !explained.has('mailnotice')
|
|
1756
|
+
const frame = (body) => {
|
|
1757
|
+
explained.add('mailnotice')
|
|
1758
|
+
return `\n\n--- MAIL NOTICE #${mailNotice} (supersedes every earlier MAIL NOTICE) ---\n${body}`
|
|
1759
|
+
}
|
|
1461
1760
|
|
|
1462
1761
|
if (!m.mentioned) {
|
|
1463
1762
|
// Emit the all-clear once, and only if we had previously announced something. Without this the
|
|
@@ -1465,7 +1764,9 @@ function mailBanner() {
|
|
|
1465
1764
|
if (!mailSig) return ''
|
|
1466
1765
|
mailSig = ''
|
|
1467
1766
|
mailNotice += 1
|
|
1468
|
-
return frame(
|
|
1767
|
+
return frame(first
|
|
1768
|
+
? '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.'
|
|
1769
|
+
: 'MAIL CLEARED: nothing addressed to you; every earlier notice is STALE.')
|
|
1469
1770
|
}
|
|
1470
1771
|
|
|
1471
1772
|
const last = m.mentions[m.mentions.length - 1] || {}
|
|
@@ -1479,7 +1780,7 @@ function mailBanner() {
|
|
|
1479
1780
|
const who = [...new Set(m.mentions.map((x) => x && x.by).filter(Boolean))].join(', ')
|
|
1480
1781
|
const urgent = m.priority === 'stop'
|
|
1481
1782
|
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
|
|
1783
|
+
+ `${urgent ? 'Handle before continuing. ' : ''}${first ? 'Call hive_wait or hive_check to read them — this notice consumed nothing.' : 'hive_check reads them.'}`)
|
|
1483
1784
|
} catch { return '' }
|
|
1484
1785
|
}
|
|
1485
1786
|
|
|
@@ -1558,6 +1859,10 @@ if (isEntryPoint && process.argv[2] === 'setup') {
|
|
|
1558
1859
|
} else if (isEntryPoint && (process.argv[2] === '--version' || process.argv[2] === '-v')) {
|
|
1559
1860
|
console.log(VERSION)
|
|
1560
1861
|
process.exit(0)
|
|
1862
|
+
} else if (isEntryPoint && (process.argv[2] === 'branch' || process.argv[2] === 'pr')) {
|
|
1863
|
+
// `npx -y hivecode-mcp@latest branch <name>` binds a room to a git branch; `pr <name>` lands its work on it.
|
|
1864
|
+
// A CLI run like setup: nobody is speaking JSON-RPC to it, so it never opens stdio.
|
|
1865
|
+
process.exit(await HB.main(process.argv.slice(2)))
|
|
1561
1866
|
} else if (isEntryPoint) {
|
|
1562
1867
|
const transport = new StdioServerTransport()
|
|
1563
1868
|
// STDIN CLOSING IS THE AGENT APP SAYING GOODBYE -- the MCP spec's stdio shutdown. Leave the
|
|
@@ -1574,7 +1879,54 @@ if (isEntryPoint && process.argv[2] === 'setup') {
|
|
|
1574
1879
|
}
|
|
1575
1880
|
|
|
1576
1881
|
/** Test seam: drive the real wait/delivery logic against a doc, with no relay and no stdio. */
|
|
1882
|
+
// THE IDLE LOOP, BY APP (27 Sep 2026). Every hive_wait that times out is a whole model turn spent
|
|
1883
|
+
// learning that nothing happened, so the default decides how many paid turns an idle agent burns --
|
|
1884
|
+
// a night shift is hundreds -- and each app bounds a tool call differently:
|
|
1885
|
+
// Claude Code a call may run ~28 h, but one past 2 min moves to a background task
|
|
1886
|
+
// (code.claude.com/docs/en/mcp): 110 s stays an ordinary blocking call, ~45% fewer
|
|
1887
|
+
// idle turns than 60 s.
|
|
1888
|
+
// Antigravity a HARD 3-minute tool deadline. Measured by Antigravity1: 110 s ran to the end, 300 s
|
|
1889
|
+
// died at exactly 180 s ("context deadline exceeded"), an error instead of a wait. So
|
|
1890
|
+
// 110 s by default and never more than 170 s there.
|
|
1891
|
+
// Apps we have not measured keep 60 s and the 300 s ceiling. The names are matched loosely; a miss
|
|
1892
|
+
// only means the old numbers.
|
|
1893
|
+
function clientName() {
|
|
1894
|
+
const c = (server && server.getClientVersion && server.getClientVersion()) || {}
|
|
1895
|
+
return String(c.name || '')
|
|
1896
|
+
}
|
|
1897
|
+
function waitPolicy() {
|
|
1898
|
+
const n = clientName()
|
|
1899
|
+
if (/claude-code/i.test(n)) return { def: 110, max: 300 }
|
|
1900
|
+
if (/antigravity/i.test(n)) return { def: 110, max: 170 }
|
|
1901
|
+
return { def: 60, max: 300 }
|
|
1902
|
+
}
|
|
1903
|
+
function waitDefaultSeconds() { return waitPolicy().def }
|
|
1904
|
+
|
|
1905
|
+
// THE LINE THAT KEEPS A CLAUDE CODE AGENT REACHABLE AFTER ITS TURN (2 Oct 2026). The rules say
|
|
1906
|
+
// hive_wait is the idle state, but agents end their turns anyway, and from then on they are deaf:
|
|
1907
|
+
// the Lead's #1 complaint in the 4-agent KCDE run, and Antigravity1 sat PARKED with a review unread
|
|
1908
|
+
// the same night. Claude Code wakes an agent when one of its background tasks ends (so does
|
|
1909
|
+
// Antigravity: see listenHow), so hive-await running in the background turns "ended its turn" into
|
|
1910
|
+
// "asleep until called". Nothing told a stranger's agent that. It is said once, on the first empty wait, and as
|
|
1911
|
+
// an order: an agent told to "consider" a command tends to describe it instead of running it. The
|
|
1912
|
+
// idle limit is 25 minutes because Claude Code stops a background task after about 30 (measured
|
|
1913
|
+
// 2 Oct: started 03:46, killed 04:16) and then tells the agent not to restart it; a listener that
|
|
1914
|
+
// ends itself on idle can be restarted. Both paths are this server's own, so the listener is the
|
|
1915
|
+
// same build, and both use forward slashes, which bash and PowerShell each accept. A token-gated
|
|
1916
|
+
// room gets no line: the agent's shell does not hold this server's token, and a listener that
|
|
1917
|
+
// cannot join is a false "listening". (tests/mcp-wait-test.mjs)
|
|
1918
|
+
function listenLine(s) {
|
|
1919
|
+
// A passive participant never answers and nobody waits on it, so there is nothing to wake it for.
|
|
1920
|
+
const how = s && !s.authed && !s.passive ? listenHow(clientName()) : ''
|
|
1921
|
+
if (!how) return ''
|
|
1922
|
+
const cmd = listenCommand({ relay: s.relay, room: s.room, name: s.name })
|
|
1923
|
+
return `\n${listenOrder(how, cmd)}\n`
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1577
1926
|
export const _test = {
|
|
1927
|
+
waitDefaultSeconds,
|
|
1928
|
+
waitPolicy,
|
|
1929
|
+
initialize: (params = {}) => server._requestHandlers.get('initialize')({ method: 'initialize', params }, {}),
|
|
1578
1930
|
waitForWork,
|
|
1579
1931
|
defaultJoinTarget,
|
|
1580
1932
|
mailbox,
|
|
@@ -1584,6 +1936,11 @@ export const _test = {
|
|
|
1584
1936
|
throttleStats: () => throttle.stats(),
|
|
1585
1937
|
setSession(s) { session = s },
|
|
1586
1938
|
getSession() { return session },
|
|
1939
|
+
tryResumeSession,
|
|
1940
|
+
// One process serves one app, so "said once" is per process; a test that plays two apps in one
|
|
1941
|
+
// process forgets between them.
|
|
1942
|
+
forget: (key) => explained.delete(key),
|
|
1943
|
+
mixedLine,
|
|
1587
1944
|
callTool: (name, args = {}) =>
|
|
1588
1945
|
server._requestHandlers.get('tools/call')({ method: 'tools/call', params: { name, arguments: args } }, {}),
|
|
1589
1946
|
}
|