hivecode-mcp 0.14.3 → 0.14.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/HIVE_RULES.md +41 -98
- package/docs/TROUBLESHOOTING.md +3 -0
- package/hive-await.js +57 -21
- package/hive-gate.js +116 -2
- package/hive-instructions.js +110 -61
- package/hive-mailbox.js +74 -7
- package/hive-mcp.js +200 -42
- package/hive-notify.js +21 -5
- package/hive-receipts.js +56 -5
- package/hive-room-mode.js +13 -0
- package/hive-room-watcher.js +30 -12
- package/hive-seen.js +135 -0
- package/hive-slack.js +27 -2
- package/hive-stop-hook.js +33 -18
- package/hive-transport.js +39 -4
- package/package.json +3 -2
- package/sync.js +172 -194
- package/docs/HIVE_RULES.md +0 -41
package/hive-mcp.js
CHANGED
|
@@ -41,12 +41,13 @@ import { checkRelay, currentRelayFor, controlUrl, PUBLIC_DEFAULT_RELAY } from '.
|
|
|
41
41
|
import { newPersonalRoom, saveRoomKey, loadRoomKey, hostToken, main as roomKeyMain } from './hive-room-key.js'
|
|
42
42
|
import { isSecuredRoom } from './token.js'
|
|
43
43
|
import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName, collapseRepeats, findClosestKnown, isDirectAddress } from './hive-mailbox.js'
|
|
44
|
-
import { INSTRUCTIONS,
|
|
44
|
+
import { INSTRUCTIONS, joinCard, REACH_BY_WAIT } from './hive-instructions.js'
|
|
45
45
|
import { makeBusy, isBusy, describeRecipient } from './hive-busy.js'
|
|
46
46
|
import * as ACT from './hive-activity.js' // HIV-P11; key MUST be 'activity'
|
|
47
47
|
import * as RH from './hive-relay-health.js' // HIV-P13; the ONLY producer of the not-syncing diagnosis
|
|
48
48
|
import * as OB from './hive-obligations.js'
|
|
49
49
|
import * as RC from './hive-receipts.js'
|
|
50
|
+
import * as SEEN from './hive-seen.js' // read state by Yjs id, never by position (0.14.4)
|
|
50
51
|
import { findSecrets, describeSecret } from './hive-secrets.js' // a key never goes into the room's chat or tasks either
|
|
51
52
|
import { makeThrottle, RULES_NUDGE } from './hive-throttle.js'
|
|
52
53
|
import * as HB from './hive-branch.js' // a room bound to a git branch: hive_join { branch }, and the branch/pr CLI
|
|
@@ -100,16 +101,19 @@ function resolveRoomRaw(link, dir) {
|
|
|
100
101
|
const SESSION_FILE = path.join(process.cwd(), SESSION_FILE_NAME)
|
|
101
102
|
const CONV = process.env.CLAUDE_CODE_SESSION_ID || process.env.ANTIGRAVITY_CONVERSATION_ID || ''
|
|
102
103
|
let keptOutOfGit = false
|
|
103
|
-
function persistSession({ dir, name, owner, cursor, authed = false, kind = 'ai', passive = false }) {
|
|
104
|
+
function persistSession({ dir, name, owner, cursor, frontier, authed = false, kind = 'ai', passive = false }) {
|
|
104
105
|
try {
|
|
105
106
|
const agents = readAgents(SESSION_FILE)
|
|
106
107
|
const prev = ownRecord(agents, { conv: CONV })
|
|
107
108
|
const chatCursor = cursor === undefined ? (prev && prev.name === name ? prev.chatCursor : undefined) : cursor
|
|
109
|
+
// The read FRONTIER (hive-seen.js) is what a resume restores; chatCursor stays beside it so an
|
|
110
|
+
// older build resuming from this file still has the count it understands.
|
|
111
|
+
const chatFrontier = frontier === undefined ? (prev && prev.name === name ? prev.chatFrontier : undefined) : (frontier || undefined)
|
|
108
112
|
// `authed`: the Stop hook reads this file, and must not order a listener into a room that checks
|
|
109
113
|
// tokens -- the agent's shell does not hold this server's token, so that listener could not join.
|
|
110
114
|
// `kind` and `passive`: a resume rejoins from this file, and without them a human came back as an
|
|
111
115
|
// AI and a passive participant as one the room waits on; the Stop hook reads `passive` too.
|
|
112
|
-
const rec = { dir, name, owner, chatCursor, ...(authed ? { authed: true } : {}),
|
|
116
|
+
const rec = { dir, name, owner, chatCursor, ...(chatFrontier ? { chatFrontier } : {}), ...(authed ? { authed: true } : {}),
|
|
113
117
|
...(kind && kind !== 'ai' ? { kind } : {}), ...(passive ? { passive: true } : {}),
|
|
114
118
|
pid: process.pid, ...(CONV ? { conv: CONV } : {}), at: Date.now() }
|
|
115
119
|
for (const [n, s] of Object.entries(agents)) if (n !== name && s.pid === process.pid) delete agents[n] // this server's earlier identity
|
|
@@ -134,7 +138,21 @@ function forgetSession() {
|
|
|
134
138
|
/** Save the read cursor so a host restart does not reopen the blind window it just closed. */
|
|
135
139
|
function persistCursor() {
|
|
136
140
|
if (!session || !session.mailbox) return
|
|
137
|
-
|
|
141
|
+
const chat = session.hive.doc.getArray('chat')
|
|
142
|
+
const frontier = session.mailbox.frontier({ prune: chat })
|
|
143
|
+
persistSession({ dir: session.dir, name: session.name, owner: session.owner, cursor: session.mailbox.cursor, frontier, authed: session.authed, kind: session.kind, passive: session.passive })
|
|
144
|
+
// FRONTIERS, NOT A COUNT (0.14.4). What is published is which messages this agent read, by Yjs id,
|
|
145
|
+
// so a message that integrated behind its position is not reported READ. Published only when the
|
|
146
|
+
// frontier really moved: this runs on every hive_wait and hive_check, and an unchanged write still
|
|
147
|
+
// wakes every observer in the room (Claude-cloud's review, attack 4).
|
|
148
|
+
if (frontier && session.mailbox.version !== session.publishedVersion) {
|
|
149
|
+
try {
|
|
150
|
+
RC.publishFrontier(session.hive.doc.getMap('cursors'), session.name, { read: frontier, at: SEEN.coveredPrefix(chat, frontier) })
|
|
151
|
+
session.publishedVersion = session.mailbox.version
|
|
152
|
+
} catch { /* a room that predates receipts */ }
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
if (frontier) return
|
|
138
156
|
// AND PUBLISH IT INTO THE ROOM. The file above is for resuming THIS session; the room copy is
|
|
139
157
|
// what makes "did they actually read it" answerable by anybody other than me. The cursor only
|
|
140
158
|
// advances inside box.take(), which is the call that hands messages to the agent -- so a cursor
|
|
@@ -192,7 +210,7 @@ function defaultJoinTarget(link, name) {
|
|
|
192
210
|
return { dir: path.join(HIVE_HOME, safeSegment(room), safeSegment(name)), link, hostedHere: false }
|
|
193
211
|
}
|
|
194
212
|
|
|
195
|
-
async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false, branch = '', repo = '', private: personal = false }) {
|
|
213
|
+
async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, chatFrontier, kind = 'ai', passive = false, branch = '', repo = '', private: personal = false }) {
|
|
196
214
|
// REFUSED BEFORE THE OLD SESSION IS TORN DOWN. joinRoom's first act is to stop whatever session
|
|
197
215
|
// exists, so validating after that point would leave an agent with a bad name holding nothing at
|
|
198
216
|
// all -- it would lose a working room to a refusal it could have been given for free.
|
|
@@ -411,6 +429,10 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
411
429
|
'it reports `connected` and `filesSynced`, and both are authoritative.',
|
|
412
430
|
].join('\n')
|
|
413
431
|
}
|
|
432
|
+
// HIV-C3: wait for presence to settle before evaluating sameNameElsewhere (Incident I-3).
|
|
433
|
+
// Waits for the first awareness update from the relay, or 1500 ms, whichever comes first.
|
|
434
|
+
await settlePresence(hive, 1500)
|
|
435
|
+
|
|
414
436
|
// ONE NAME, ONE PARTICIPANT (Q581). Everything in a room is keyed by name, so a second agent under
|
|
415
437
|
// a name already here is the same identity to everybody: both are told they hold each other's
|
|
416
438
|
// claims, both read each other's mail, and their writes land on each other's files. Measured
|
|
@@ -440,8 +462,31 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
440
462
|
// process was down is still delivered rather than falling into the old blind window.
|
|
441
463
|
const chatLen = hive.doc.getArray('chat').length
|
|
442
464
|
const startAt = Number.isFinite(Number(chatCursor)) ? Number(chatCursor) : chatLen
|
|
443
|
-
session.mailbox = createMailbox({ me: name, startAt })
|
|
465
|
+
session.mailbox = createMailbox({ me: name, startAt, frontier: chatFrontier && typeof chatFrontier === 'object' ? chatFrontier : null })
|
|
444
466
|
persistCursor()
|
|
467
|
+
session.joined = Date.now()
|
|
468
|
+
|
|
469
|
+
// Twin join re-check (0.14.4 C3): if two case-variant names joined at the same moment both see nobody.
|
|
470
|
+
// Re-check once more about 3 s after joining. If a twin has appeared, the LATER joiner
|
|
471
|
+
// (higher join time) gets one line telling it to rejoin under another name.
|
|
472
|
+
setTimeout(() => {
|
|
473
|
+
try {
|
|
474
|
+
if (!session || session.name !== name || session.room !== room) return
|
|
475
|
+
const dupesLater = typeof hive.sameNameElsewhere === 'function' ? hive.sameNameElsewhere() : []
|
|
476
|
+
if (!dupesLater.length) return
|
|
477
|
+
const d = dupesLater[0]
|
|
478
|
+
const myJoined = session.joined || 0
|
|
479
|
+
const theirJoined = d.joined || 0
|
|
480
|
+
const myId = hive.provider && hive.provider.awareness ? hive.provider.awareness.clientID : 0
|
|
481
|
+
const isLater = theirJoined < myJoined || (theirJoined === myJoined && (d.clientID || 0) < myId)
|
|
482
|
+
if (isLater) {
|
|
483
|
+
const taken = new Set(hive.members().map((m) => String(m.name).toLowerCase()))
|
|
484
|
+
let n = 2
|
|
485
|
+
while (taken.has(`${name}-${n}`.toLowerCase())) n++
|
|
486
|
+
session.twinWarning = `A twin named "${d.name}" joined at the same moment. You are the later joiner: call hive_join with a different name to rejoin under another name (e.g. hive_join { name: "${name}-${n}" }).`
|
|
487
|
+
}
|
|
488
|
+
} catch { }
|
|
489
|
+
}, 3000).unref?.()
|
|
445
490
|
|
|
446
491
|
const others = hive.members().filter((m) => m.name !== name).map((m) => `${m.name}(${m.kind})`)
|
|
447
492
|
const mixedNote = mixedLine(hive.members(), name, VERSION_LABEL)
|
|
@@ -488,7 +533,7 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
488
533
|
const s = session
|
|
489
534
|
if (!s || !s.mailbox) return
|
|
490
535
|
catchUp(s)
|
|
491
|
-
const m = s.mailbox.peek(hive.doc.getArray('chat')
|
|
536
|
+
const m = newMailOnly(s.mailbox.peek(hive.doc.getArray('chat')))
|
|
492
537
|
// Compare with !== rather than >: after hive_wait consumes the backlog the count drops to
|
|
493
538
|
// 0, and a later single mention must still ring. `>` would go permanently silent.
|
|
494
539
|
if (!m.mentioned || m.mentions.length === lastMailSignal) return
|
|
@@ -525,7 +570,7 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
525
570
|
// THE CORE, NOT THE WHOLE FILE. This reply stays in the agent's transcript for the rest of its
|
|
526
571
|
// session, so every character is paid again on every later turn: pasting all of HIVE_RULES.md made
|
|
527
572
|
// the join ~1,900 tokens (27 Sep 2026, bench/token-cost.mjs). The full text is on disk in the folder.
|
|
528
|
-
const rulesText =
|
|
573
|
+
const rulesText = joinCard(reachLine(session))
|
|
529
574
|
// A TOKEN IS A PERSON (3 Oct 2026). The invite link used to carry the joiner's own token, labelled
|
|
530
575
|
// as the thing to forward, so an agent that shared it handed whoever read it the joiner's identity
|
|
531
576
|
// and access -- in the README's own flow, the host's maintainer token to the outside agent scoped to
|
|
@@ -585,7 +630,8 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
585
630
|
: (browserUrl ? `\nbrowser (people without an agent): ${browserUrl}` : '')
|
|
586
631
|
// No "announce your version" rule any more: since 29 Sep the room flags a mixed build itself
|
|
587
632
|
// (sync.js checkBuilds), and the joiner is told here, by name, when one is already in the room.
|
|
588
|
-
|
|
633
|
+
const ls = listenerStatusFor({ name, room, useToken, passive })
|
|
634
|
+
return `${branchNote}${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION_LABEL}\nlistener: ${ls.card}\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}`
|
|
589
635
|
}
|
|
590
636
|
|
|
591
637
|
// A MIXED BUILD, TOLD TO THE ONE WHO CAN ACT ON IT (2 Oct 2026). The room's own warning names
|
|
@@ -657,12 +703,29 @@ function mailbox() {
|
|
|
657
703
|
// redoes old instructions. Both now meet at the room's per-member cursor, which only moves forward:
|
|
658
704
|
// whichever of them handed a message over, the other knows. A cursor past the end of chat belongs to
|
|
659
705
|
// a rebuilt room and is ignored, as the mailbox itself does. Every peek and take goes through here.
|
|
706
|
+
//
|
|
707
|
+
// HANDED IS NOT READ (0.14.4, Claude-cloud's attack 6, incident I-1). The listener used to publish this
|
|
708
|
+
// agent's READ cursor when it woke on a mention, and this seeked the mailbox past it. An app that was
|
|
709
|
+
// never woken (Antigravity), or crashed before its turn, then never got that mail on its next session,
|
|
710
|
+
// and hive_delivered told the sender READ. Now the room record carries two frontiers: `read` (moved
|
|
711
|
+
// only by this agent's own hive_wait / hive_check) is restored here; `handed` (moved by the listener
|
|
712
|
+
// too) only marks a mention as already handed -- it is delivered once more, tagged, never dropped.
|
|
713
|
+
// A record from an older build has only a count, `at`, and can't say which of the two it was, so
|
|
714
|
+
// it is taken as HANDED: at worst one mention is shown twice, never lost.
|
|
660
715
|
function catchUp(s) {
|
|
661
716
|
if (!s || !s.mailbox) return
|
|
662
717
|
try {
|
|
718
|
+
const chat = s.hive.doc.getArray('chat')
|
|
663
719
|
const c = RC.cursorOf(s.hive.doc.getMap('cursors'), s.name)
|
|
664
|
-
|
|
665
|
-
|
|
720
|
+
if (!c) return
|
|
721
|
+
const when = c.handedAt || (c.ts ? new Date(Number(c.ts)).toTimeString().slice(0, 5) : '')
|
|
722
|
+
if (c.ver >= 2 && c.read) {
|
|
723
|
+
s.mailbox.seedFrontier(c.read, chat)
|
|
724
|
+
if (c.handed) s.mailbox.handedBy(c.handed, when)
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
const at = Number(c.at) || 0
|
|
728
|
+
if (at > 0 && at <= chat.length) s.mailbox.handedBy(SEEN.frontierOfPrefix(chat, at).toJSON(), when)
|
|
666
729
|
} catch { /* a room that predates receipts */ }
|
|
667
730
|
}
|
|
668
731
|
function chatArray() { return requireSession().hive.doc.getArray('chat').toArray() }
|
|
@@ -723,7 +786,7 @@ function waitForWork(timeoutMs, opts = {}) {
|
|
|
723
786
|
// `baseChatLen` here and returned `chat.slice(baseChatLen)`, which meant anything posted
|
|
724
787
|
// between one wait returning and the next one starting — the whole time the agent was
|
|
725
788
|
// working — was returned by no path at all. Two paths also hardcoded `newChat: []`.
|
|
726
|
-
const deliver = (extra) => { const got = box.take(chat
|
|
789
|
+
const deliver = (extra) => { const got = box.take(chat); persistCursor(); return { ...got, ...extra } } // the Y.Array: read by id
|
|
727
790
|
// Only wake for ambient chatter if asked to. Defaults to waking on anything, as before.
|
|
728
791
|
const wakeOnMentionOnly = opts.wakeOn === 'mention'
|
|
729
792
|
const mentionsMe = (t) => mentionsName(t, me)
|
|
@@ -745,7 +808,7 @@ function waitForWork(timeoutMs, opts = {}) {
|
|
|
745
808
|
if (dt.length) return finish(deliver({ paused: true, mentioned: true, tasks: dt }))
|
|
746
809
|
// A paused agent stays deaf to chatter but wakes when directly addressed. Unchanged,
|
|
747
810
|
// except the messages come from the mailbox now, so none are skipped.
|
|
748
|
-
if (box.peek(chat
|
|
811
|
+
if (box.peek(chat).mentioned) finish(deliver({ paused: true, tasks: [] }))
|
|
749
812
|
}
|
|
750
813
|
const onCtl = check, onChat = check, onTask = check
|
|
751
814
|
controls.observe(onCtl); chat.observe(onChat); tmap.observe(onTask)
|
|
@@ -765,8 +828,8 @@ function waitForWork(timeoutMs, opts = {}) {
|
|
|
765
828
|
if (ready.length) return Promise.resolve(deliver({ tasks: ready }))
|
|
766
829
|
// Anything already unread is handed over immediately rather than waited past — that backlog
|
|
767
830
|
// is exactly what used to be unreachable.
|
|
768
|
-
if (box.hasUnread(chat
|
|
769
|
-
const pending = box.peek(chat
|
|
831
|
+
if (box.hasUnread(chat)) {
|
|
832
|
+
const pending = box.peek(chat)
|
|
770
833
|
if (!wakeOnMentionOnly || pending.mentioned) return Promise.resolve(deliver({ tasks: [] }))
|
|
771
834
|
}
|
|
772
835
|
return new Promise((resolve) => {
|
|
@@ -774,7 +837,7 @@ function waitForWork(timeoutMs, opts = {}) {
|
|
|
774
837
|
const finish = (v) => { if (!done) { done = true; tmap.unobserve(onTask); chat.unobserve(onChat); controls.unobserve(onCtl); if (until) { try { state.unobserve(onState) } catch { /* never observed */ } } resolve(v) } }
|
|
775
838
|
const onTask = () => { const a = accepted(); if (a.length) finish(deliver({ tasks: a })) }
|
|
776
839
|
const onChat = () => {
|
|
777
|
-
const seen = box.peek(chat
|
|
840
|
+
const seen = box.peek(chat)
|
|
778
841
|
if (!seen.unreadCount) return // only this agent's own messages — not mail
|
|
779
842
|
if (wakeOnMentionOnly && !seen.mentioned) return // ambient chatter, and the caller opted out
|
|
780
843
|
finish(deliver({ tasks: accepted() }))
|
|
@@ -787,7 +850,7 @@ function waitForWork(timeoutMs, opts = {}) {
|
|
|
787
850
|
tmap.observe(onTask); chat.observe(onChat); controls.observe(onCtl); if (until) state.observe(onState)
|
|
788
851
|
// On timeout, flush anything that arrived rather than dropping it: a message must never
|
|
789
852
|
// expire unread just because the wait window closed on it.
|
|
790
|
-
setTimeout(() => finish(box.hasUnread(chat
|
|
853
|
+
setTimeout(() => finish(box.hasUnread(chat) ? deliver({ tasks: accepted() }) : null), timeoutMs)
|
|
791
854
|
})
|
|
792
855
|
}
|
|
793
856
|
|
|
@@ -828,6 +891,48 @@ function collidedNotice(s) {
|
|
|
828
891
|
} catch { return '' }
|
|
829
892
|
}
|
|
830
893
|
|
|
894
|
+
function twinNotice(s) {
|
|
895
|
+
if (!s || !s.twinWarning) return ''
|
|
896
|
+
return `\n\n⚠ ${s.twinWarning}`
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
async function settlePresence(hive, timeoutMs = 1500) {
|
|
900
|
+
if (hive && hive.provider && hive.provider.awareness && typeof hive.sameNameElsewhere === 'function' && !hive.sameNameElsewhere().length) {
|
|
901
|
+
await new Promise((resolve) => {
|
|
902
|
+
let timer = null
|
|
903
|
+
let done = false
|
|
904
|
+
const finish = () => {
|
|
905
|
+
if (!done) {
|
|
906
|
+
done = true
|
|
907
|
+
clearTimeout(timer)
|
|
908
|
+
try { hive.provider.awareness.off('change', onChange) } catch { }
|
|
909
|
+
resolve()
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
const onChange = (_changes, origin) => {
|
|
913
|
+
if (origin !== 'local') finish()
|
|
914
|
+
}
|
|
915
|
+
timer = setTimeout(finish, timeoutMs)
|
|
916
|
+
hive.provider.awareness.on('change', onChange)
|
|
917
|
+
})
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function listenerStatusFor({ session: s, name, room, useToken, passive } = {}) {
|
|
922
|
+
const targetName = name || (s && s.name)
|
|
923
|
+
const targetRoom = room || (s && s.room)
|
|
924
|
+
const isPassive = passive !== undefined ? !!passive : !!(s && s.passive)
|
|
925
|
+
const isAuthed = useToken !== undefined ? !!useToken : !!(s && s.authed)
|
|
926
|
+
const how = !isAuthed && !isPassive ? listenHow(clientName()) : ''
|
|
927
|
+
if (!how) return { enabled: false, status: 'not used on this client', card: 'idle with hive_wait' }
|
|
928
|
+
const armed = isArmed({ name: targetName, room: targetRoom })
|
|
929
|
+
return {
|
|
930
|
+
enabled: true,
|
|
931
|
+
status: armed ? 'running' : 'not running',
|
|
932
|
+
card: armed ? 'running' : 'not running',
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
831
936
|
// A KEY IN A FILE KEEPS THE FILE HERE (3 Oct 2026; sync.js heldForSecret). The agent that pasted it
|
|
832
937
|
// is usually the one that needs telling, and the room line about it is in that agent's own name.
|
|
833
938
|
function withheldNotice(s) {
|
|
@@ -844,13 +949,13 @@ function withheldNotice(s) {
|
|
|
844
949
|
|
|
845
950
|
const TOOLS = [
|
|
846
951
|
{ 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.',
|
|
847
|
-
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
|
|
952
|
+
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' }, 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' } } } },
|
|
848
953
|
{ 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.',
|
|
849
954
|
inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name' }, text: { type: 'string', description: 'the task' } }, required: ['to', 'text'] } },
|
|
850
955
|
{ name: 'hive_read_tasks', description: 'Tasks aimed at you (act only on accepted ones) and all pending tasks awaiting approval.',
|
|
851
956
|
inputSchema: { type: 'object', properties: {} } },
|
|
852
|
-
{ name: 'hive_wait', description: '
|
|
853
|
-
inputSchema: { type: 'object', properties: { timeoutSeconds: { type: 'number', description: 'max seconds to wait (default 60
|
|
957
|
+
{ name: 'hive_wait', description: '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. Idle with it on clients that have no listener (hive_join says which).',
|
|
958
|
+
inputSchema: { type: 'object', properties: { timeoutSeconds: { type: 'number', description: 'max seconds to wait (default 60; 110 in Claude Code and Antigravity; max 300)' }, 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'] } } } },
|
|
854
959
|
{ 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.',
|
|
855
960
|
inputSchema: { type: 'object', properties: { peek: { type: 'boolean', description: 'true = look without marking messages read' } } } },
|
|
856
961
|
{ name: 'hive_approve', description: 'Approve a pending task for an AI you own; it may then act on it.',
|
|
@@ -861,8 +966,8 @@ const TOOLS = [
|
|
|
861
966
|
inputSchema: { type: 'object', properties: { files: { type: 'array', items: { type: 'string' }, description: 'manifest files to review' }, verdict: { type: 'string', enum: ['pass', 'fail'], description: 'review verdict: pass or fail' }, note: { type: 'string', description: 'review notes explaining verdict' } }, required: ['files', 'verdict'] } },
|
|
862
967
|
{ name: 'hive_complete', description: 'Mark a task you were doing as done, with an optional note.',
|
|
863
968
|
inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'task id' }, note: { type: 'string', description: 'what was done' } }, required: ['id'] } },
|
|
864
|
-
{ 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.
|
|
865
|
-
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
|
|
969
|
+
{ 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.',
|
|
970
|
+
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' }, replyWithinMinutes: { type: 'number', description: 'remind you after this many minutes (default 10)' } }, required: ['text'] } },
|
|
866
971
|
{ 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.',
|
|
867
972
|
inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'recent addressed messages to check (default 10)' } } } },
|
|
868
973
|
{ 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.',
|
|
@@ -872,13 +977,13 @@ const TOOLS = [
|
|
|
872
977
|
{ 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.',
|
|
873
978
|
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' } } } },
|
|
874
979
|
{ name: 'hive_read_chat', description: 'Read recent room chat: what people and agents are doing and saying.',
|
|
875
|
-
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 (
|
|
980
|
+
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 (else cut at 280 chars)' } } } },
|
|
876
981
|
{ 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.',
|
|
877
982
|
inputSchema: { type: 'object', properties: {} } },
|
|
878
|
-
{ name: 'hive_claim', description: 'Claim a file (or region) before you edit it
|
|
983
|
+
{ name: 'hive_claim', description: 'Claim a file (or region) before you edit it (new behaviour others rely on: post its design first); if refused, someone holds it, so do other work. A claim lasts 5 min, renews when you claim again, and holds at most 15 min in a row (then a 15 min wait): release and re-claim between long steps. hive_release when done.',
|
|
879
984
|
inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'file path or region id' }, intent: { type: 'string', description: 'what you intend to do' } }, required: ['region'] } },
|
|
880
|
-
{ name: 'hive_release', description: 'Hand a file back.
|
|
881
|
-
inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'file path or region id' }, force: { type: 'boolean', description: 'release despite a failing gate; recorded
|
|
985
|
+
{ name: 'hive_release', description: 'Hand a file back. A room gate your person approved runs in your folder first; a failing check REFUSES the release: the file stays yours and retrying will not help. Fix it, or force with a reason to abandon the work. An unapproved gate does not run (Q592).',
|
|
986
|
+
inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'file path or region id' }, force: { type: 'boolean', description: 'release despite a failing gate; recorded and announced; for abandoned work only' }, why: { type: 'string', description: 'why the work is abandoned (give it whenever force is true)' } }, required: ['region'] } },
|
|
882
987
|
{ name: 'hive_claims', description: 'The live claim board: who holds which file. Authoritative; read it instead of asking the room.',
|
|
883
988
|
inputSchema: { type: 'object', properties: {} } },
|
|
884
989
|
{ 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. Changing existing lines: pass old (whole lines, exactly as you read them) and new; if those lines changed since you read them it is refused and nothing is written. A whole new file: pass content.',
|
|
@@ -893,11 +998,11 @@ const TOOLS = [
|
|
|
893
998
|
inputSchema: { type: 'object', properties: {} } },
|
|
894
999
|
{ name: 'hive_leave', description: 'Leave the room and stop syncing.',
|
|
895
1000
|
inputSchema: { type: 'object', properties: {} } },
|
|
896
|
-
{ name: 'hive_set_state', description: '
|
|
1001
|
+
{ name: 'hive_set_state', description: 'Set a room-wide state flag (e.g. key="contract", val="READY") that blocks or unblocks agents waiting on it. Any member can set one. Short enum values only; text belongs in files.',
|
|
897
1002
|
inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'flag name' }, val: { type: 'string', description: 'short value' } }, required: ['key', 'val'] } },
|
|
898
1003
|
{ name: 'hive_read_state', description: 'Read the global project state flags.',
|
|
899
1004
|
inputSchema: { type: 'object', properties: {} } },
|
|
900
|
-
{ name: 'hive_set_schema', description: '
|
|
1005
|
+
{ name: 'hive_set_schema', description: '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"}}}',
|
|
901
1006
|
inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'state key' }, rules: { type: 'object', description: 'the JSON rules' } }, required: ['key', 'rules'] } },
|
|
902
1007
|
{ 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.',
|
|
903
1008
|
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'] } },
|
|
@@ -1291,7 +1396,7 @@ async function dispatch(req) {
|
|
|
1291
1396
|
return text(`${mine.length} message(s) addressed to you:\n` + mine.map(formatMessage).join('\n'))
|
|
1292
1397
|
}
|
|
1293
1398
|
if (args.unreadOnly) {
|
|
1294
|
-
const seen = mailbox().peek(
|
|
1399
|
+
const seen = mailbox().peek(requireSession().hive.doc.getArray('chat'))
|
|
1295
1400
|
if (!seen.unreadCount) return text('(nothing unread)')
|
|
1296
1401
|
return text(`${seen.unreadCount} unread (still marked unread — hive_check or hive_wait will hand these over):\n` + seen.messages.map(formatMessage).join('\n'))
|
|
1297
1402
|
}
|
|
@@ -1313,7 +1418,8 @@ async function dispatch(req) {
|
|
|
1313
1418
|
const s = requireSession()
|
|
1314
1419
|
const all = chatArray()
|
|
1315
1420
|
const box = mailbox()
|
|
1316
|
-
const
|
|
1421
|
+
const ychat = s.hive.doc.getArray('chat') // the mailbox reads by Yjs id; `all` stays for the plain-array helpers below
|
|
1422
|
+
const got = args.peek ? box.peek(ychat) : box.take(ychat)
|
|
1317
1423
|
if (!args.peek) persistCursor()
|
|
1318
1424
|
const tasks = [...s.hive.doc.getMap('tasks').values()].filter((t) => t.to === s.name && t.status === 'accepted')
|
|
1319
1425
|
// WHO IS STILL WAITING ON YOU. Read mail is not answered mail: the cursor advances the
|
|
@@ -1357,8 +1463,18 @@ async function dispatch(req) {
|
|
|
1357
1463
|
parts.push('(your claims were just refreshed — the files you hold stay yours while you deal with this)')
|
|
1358
1464
|
parts.push(got.mentions.filter((m) => priorityOf(m.text) === 'stop').map(formatMessage).join('\n'))
|
|
1359
1465
|
} else if (got.mentioned) {
|
|
1360
|
-
|
|
1361
|
-
|
|
1466
|
+
// A mention your listener already handed over is shown ONCE more, on its own line and marked,
|
|
1467
|
+
// never as new mail to answer (A6): if your app was woken with it, it is probably done.
|
|
1468
|
+
const fresh = got.mentions.filter((m) => !m.handed)
|
|
1469
|
+
const handedOver = got.mentions.filter((m) => m.handed)
|
|
1470
|
+
if (fresh.length) {
|
|
1471
|
+
parts.push(`📣 ${fresh.length} message(s) addressed to YOU — answer at your next safe stopping point:`)
|
|
1472
|
+
parts.push(fresh.map(formatMessage).join('\n'))
|
|
1473
|
+
}
|
|
1474
|
+
if (handedOver.length) {
|
|
1475
|
+
parts.push(`↪ ${handedOver.length} already handed to you by your listener (shown once; if you already acted on it, ignore it):`)
|
|
1476
|
+
parts.push(handedOver.map(formatMessage).join('\n'))
|
|
1477
|
+
}
|
|
1362
1478
|
}
|
|
1363
1479
|
const ambient = got.messages.filter((m) => !mentionsName(m.text, s.name))
|
|
1364
1480
|
const dig = ambientDigest(ambient)
|
|
@@ -1378,7 +1494,7 @@ async function dispatch(req) {
|
|
|
1378
1494
|
}
|
|
1379
1495
|
}
|
|
1380
1496
|
} catch { }
|
|
1381
|
-
return text(parts.join('\n\n') + owedLine + unsharedPart + unwritableNotice(s) + withheldNotice(s) + collidedNotice(s))
|
|
1497
|
+
return text(parts.join('\n\n') + owedLine + unsharedPart + unwritableNotice(s) + withheldNotice(s) + collidedNotice(s) + twinNotice(s))
|
|
1382
1498
|
}
|
|
1383
1499
|
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)') }
|
|
1384
1500
|
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') }
|
|
@@ -1461,9 +1577,15 @@ async function dispatch(req) {
|
|
|
1461
1577
|
// Direct address is separated from ambient traffic on every path. It used to be that
|
|
1462
1578
|
// only a PAUSED agent got this distinction, so a working agent could not tell "stop,
|
|
1463
1579
|
// wrong file" from two other agents talking to each other.
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1580
|
+
// A mention your listener already handed over is shown once more, marked, apart from new mail (A6).
|
|
1581
|
+
const mentionBlock = (r) => {
|
|
1582
|
+
const all = r.mentions || []
|
|
1583
|
+
const fresh = all.filter((m) => !m.handed), handedOver = all.filter((m) => m.handed)
|
|
1584
|
+
const blocks = []
|
|
1585
|
+
if (fresh.length) blocks.push(`${r.priority === 'stop' ? '🛑 STOP — handle this BEFORE anything else' : '📣 ADDRESSED TO YOU'}:\n${fresh.map(formatMessage).join('\n')}`)
|
|
1586
|
+
if (handedOver.length) blocks.push(`${handedOver.some((m) => priorityOf(m.text) === 'stop') ? '🛑 URGENT, already handed to you by your listener — if you have not acted on it, do now' : '↪ already handed to you by your listener (shown once; if you already acted on it, ignore it)'}:\n${handedOver.map(formatMessage).join('\n')}`)
|
|
1587
|
+
return blocks.join('\n\n')
|
|
1588
|
+
}
|
|
1467
1589
|
// Both delivery paths go through the SAME digest. They used to format ambient traffic
|
|
1468
1590
|
// separately, which is how two copies of a rule drift apart -- the exact hazard that
|
|
1469
1591
|
// already bit the duplicated reconcile() and the two HIVE_RULES copies.
|
|
@@ -1484,22 +1606,25 @@ async function dispatch(req) {
|
|
|
1484
1606
|
const waitMsg = armed
|
|
1485
1607
|
? '(nothing yet — your background listener is running and will wake you when called: you may end your turn now.)'
|
|
1486
1608
|
: '(nothing yet — no approved work or new messages in the wait window. Call hive_wait again to keep waiting.)'
|
|
1487
|
-
return text(`${waitMsg}${owedStr}${connStr}${unwritableNotice(sess)}${withheldNotice(sess)}${collidedNotice(sess)}${stateStr}${rulesStr}${ll}`)
|
|
1609
|
+
return text(`${waitMsg}${owedStr}${connStr}${unwritableNotice(sess)}${withheldNotice(sess)}${collidedNotice(sess)}${twinNotice(sess)}${stateStr}${rulesStr}${ll}`)
|
|
1488
1610
|
}
|
|
1489
1611
|
const parts = []
|
|
1490
1612
|
if (r.resumed) parts.push('▶ You were RESUMED by mission control — continue.')
|
|
1491
1613
|
const mb = mentionBlock(r); if (mb) parts.push(mb)
|
|
1492
1614
|
if (r.tasks.length) parts.push(`APPROVED WORK — do these now, then hive_complete each:\n${r.tasks.map(fmtTask).join('\n')}`)
|
|
1493
1615
|
const ab = ambientBlock(r); if (ab) parts.push(ab)
|
|
1494
|
-
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()))
|
|
1616
|
+
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()) + twinNotice(requireSession()))
|
|
1495
1617
|
}
|
|
1496
1618
|
case 'hive_delivered': {
|
|
1497
1619
|
const s2 = requireSession()
|
|
1498
1620
|
const conn = (() => { try { return connectionOf(s2.hive) } catch { return null } })()
|
|
1499
1621
|
const connected = !conn || !!conn.connected
|
|
1500
1622
|
const here = new Set(s2.hive.members().map((m) => String(m.name).toLowerCase()))
|
|
1623
|
+
const ych = s2.hive.doc.getArray('chat')
|
|
1624
|
+
const its = SEEN.itemsOf(ych)
|
|
1501
1625
|
const rs = RC.receipts({
|
|
1502
|
-
chat:
|
|
1626
|
+
chat: its.map((x) => x.value),
|
|
1627
|
+
ids: its.map((x) => x.id), // so a message behind someone's position is UNREAD, not a false READ
|
|
1503
1628
|
cursors: s2.hive.doc.getMap('cursors'),
|
|
1504
1629
|
me: s2.name,
|
|
1505
1630
|
present: (n) => here.has(String(n).toLowerCase()),
|
|
@@ -1737,6 +1862,7 @@ Then call hive_release.`)
|
|
|
1737
1862
|
const r = requireSession().hive.release(region, { force: !!args.force, why: String(args.why || '') })
|
|
1738
1863
|
if (r && r.ok === false) return text(r.message)
|
|
1739
1864
|
const check = await releaseCheck(requireSession(), region)
|
|
1865
|
+
if (r && r.gateSkipped) return text(`released ${region}, NOT CHECKED\n${r.message}${check}`)
|
|
1740
1866
|
if (r && r.forced) return text(`released ${region} WITHOUT passing the gate — recorded against your name in the room${check}`)
|
|
1741
1867
|
return text(`released ${region}${r && r.gated ? ' (gate passed)' : ''}${check}`)
|
|
1742
1868
|
}
|
|
@@ -1853,16 +1979,19 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
|
|
|
1853
1979
|
// reportable and "where is my source going" was not. A bare relay URL is not an
|
|
1854
1980
|
// answer to the second -- the public relay and a company's own are the same shape.
|
|
1855
1981
|
const pv = checkRelay({ relay: s.relay, env: process.env, dir: s.dir, fs, room: s.room })
|
|
1982
|
+
const ls = listenerStatusFor({ session: s })
|
|
1856
1983
|
return text({
|
|
1857
1984
|
room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION, ...(BUILD ? { build: BUILD } : {}), ...(updateNotice() ? { update: updateNotice() } : {}),
|
|
1858
1985
|
...(clientName() ? { app: clientName() } : {}), // which agent app this is: support needs it, and so does waitPolicy
|
|
1986
|
+
listener: ls.status,
|
|
1859
1987
|
privacy: pv.headline,
|
|
1860
1988
|
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',
|
|
1861
1989
|
...(pv.warn ? { privacyWarning: pv.warn } : {}),
|
|
1862
1990
|
...(pv.pin && pv.pin.pinned ? { relayPin: pv.pin.origins.join(', ') } : {}),
|
|
1863
|
-
unread: mailbox().peek(
|
|
1991
|
+
unread: mailbox().peek(requireSession().hive.doc.getArray('chat')).unreadCount,
|
|
1864
1992
|
connected: conn ? conn.connected : null,
|
|
1865
1993
|
filesSynced: conn ? `${conn.filesSynced}/${conn.files}` : null,
|
|
1994
|
+
...(s.twinWarning ? { twinWarning: s.twinWarning } : {}),
|
|
1866
1995
|
// How full this client's bounded working set is: files open / the bound (HIVE_MAX_OPEN_FILES, or
|
|
1867
1996
|
// 4096 on one socket, 128 with a socket per file). At the bound, files that were in the room
|
|
1868
1997
|
// at join arrive when touched; new ones still open (sync.js newRemote), and `waiting` counts
|
|
@@ -1875,6 +2004,9 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
|
|
|
1875
2004
|
...(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' } } : {}),
|
|
1876
2005
|
...(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' } } : {}),
|
|
1877
2006
|
...(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' } } : {}),
|
|
2007
|
+
// What only THIS folder needed to know, kept off the room's chat (0.14.4, C5): a local copy kept after
|
|
2008
|
+
// a room write, a file that differed at join, a teammate on the same release from another commit.
|
|
2009
|
+
...((() => { try { const n = s.hive.notices ? s.hive.notices() : []; return n.length ? { thisFolderOnly: n.slice(-8).map((x) => `${new Date(x.at).toISOString().slice(11, 19)}Z ${x.text}`) } : {} } catch { return {} } })()),
|
|
1878
2010
|
})
|
|
1879
2011
|
}
|
|
1880
2012
|
// HIV-P11. A deliberate leave is the one departure the room KNOWS about, so the activity
|
|
@@ -1946,11 +2078,19 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
|
|
|
1946
2078
|
let mailSig = ''
|
|
1947
2079
|
let mailNotice = 0
|
|
1948
2080
|
|
|
2081
|
+
// A mention this agent's listener already handed over is not NEW mail for a notice (A6). hive_wait
|
|
2082
|
+
// and hive_check still deliver it once more, marked, so an app that was never woken still gets it;
|
|
2083
|
+
// a notice on every tool reply would bring back the 19-duplicates storm (listener-mail-once-test).
|
|
2084
|
+
// An URGENT one still rings: a stand-down nobody saw is the worse of the two failures.
|
|
2085
|
+
function newMailOnly(m) {
|
|
2086
|
+
const mentions = (m.mentions || []).filter((x) => !x.handed || priorityOf(x.text) === 'stop')
|
|
2087
|
+
return { ...m, mentions, mentioned: mentions.length > 0 }
|
|
2088
|
+
}
|
|
1949
2089
|
function mailBanner() {
|
|
1950
2090
|
try {
|
|
1951
2091
|
if (!session || !session.mailbox) return ''
|
|
1952
2092
|
catchUp(session)
|
|
1953
|
-
const m = session.mailbox.peek(
|
|
2093
|
+
const m = newMailOnly(session.mailbox.peek(session.hive.doc.getArray('chat')))
|
|
1954
2094
|
// EVERY notice says it supersedes the earlier ones: a context is append-only, so an old "you have
|
|
1955
2095
|
// mail" can only be retired by a newer notice that says so. What is said once per session (see
|
|
1956
2096
|
// firstTime) is the explanation in the body.
|
|
@@ -2034,7 +2174,7 @@ function urgentGate(name) {
|
|
|
2034
2174
|
try {
|
|
2035
2175
|
if (!URGENT_GATED.has(name) || !session || !session.mailbox) return ''
|
|
2036
2176
|
catchUp(session)
|
|
2037
|
-
const m = session.mailbox.peek(
|
|
2177
|
+
const m = session.mailbox.peek(session.hive.doc.getArray('chat'))
|
|
2038
2178
|
const urgent = (m.mentions || []).filter((x) => x && priorityOf(x.text) === 'stop')
|
|
2039
2179
|
if (!urgent.length) return ''
|
|
2040
2180
|
const shown = urgent.slice(-3).map((x) => ` ${formatMessage(x)}`.slice(0, 600)).join('\n')
|
|
@@ -2093,6 +2233,11 @@ if (isEntryPoint && process.argv[2] === 'setup') {
|
|
|
2093
2233
|
// `npx -y hivecode-mcp@latest branch <name>` binds a room to a git branch; `pr <name>` lands its work on it.
|
|
2094
2234
|
// A CLI run like setup: nobody is speaking JSON-RPC to it, so it never opens stdio.
|
|
2095
2235
|
process.exit(await HB.main(process.argv.slice(2)))
|
|
2236
|
+
} else if (isEntryPoint && process.argv[2] === 'gate') {
|
|
2237
|
+
// `gate allow --dir <folder> --cmd "<exact>"`: a person approves the room's hand-back check for this
|
|
2238
|
+
// machine (0.14.4 S-1). A command, never a tool: an agent persuaded by a message cannot consent.
|
|
2239
|
+
const { main: gateMain } = await import('./hive-gate.js')
|
|
2240
|
+
process.exit(await gateMain(process.argv.slice(2)))
|
|
2096
2241
|
} else if (isEntryPoint && (process.argv[2] === 'invite' || process.argv[2] === 'revoke')) {
|
|
2097
2242
|
// `invite <name>` makes one person's link into a personal room this machine hosts; `revoke <name>`
|
|
2098
2243
|
// takes it back. Commands rather than tools: see hive-room-key.js for why.
|
|
@@ -2177,6 +2322,16 @@ function waitDefaultSeconds() { return waitPolicy().def }
|
|
|
2177
2322
|
// same build, and both use forward slashes, which bash and PowerShell each accept. A token-gated
|
|
2178
2323
|
// room gets no line: the agent's shell does not hold this server's token, and a listener that
|
|
2179
2324
|
// cannot join is a false "listening". (tests/mcp-wait-test.mjs)
|
|
2325
|
+
// THE JOIN CARD SAYS HOW TO STAY REACHABLE ON THIS CLIENT (0.14.4 rules rewrite). The rules tell
|
|
2326
|
+
// every agent to "do what hive_join told you", so join must always tell it something true: the
|
|
2327
|
+
// listener where the server knows one works, hive_wait everywhere else (other apps, token-checked
|
|
2328
|
+
// rooms, passive participants).
|
|
2329
|
+
function reachLine(s) {
|
|
2330
|
+
const how = s && !s.authed && !s.passive ? listenHow(clientName()) : ''
|
|
2331
|
+
if (!how) return REACH_BY_WAIT
|
|
2332
|
+
const cmd = listenCommand({ relay: s.relay, room: s.room, name: s.name, app: clientName() })
|
|
2333
|
+
return `1. Stay reachable: before your turn ends, start your listener. ${how} It ends, and that wakes you, when you are addressed or after ${LISTEN_IDLE_MIN} quiet minutes; then start it again:\n ${cmd}`
|
|
2334
|
+
}
|
|
2180
2335
|
function listenLine(s) {
|
|
2181
2336
|
// A passive participant never answers and nobody waits on it, so there is nothing to wake it for.
|
|
2182
2337
|
const how = s && !s.authed && !s.passive ? listenHow(clientName()) : ''
|
|
@@ -2204,6 +2359,9 @@ export const _test = {
|
|
|
2204
2359
|
// process forgets between them.
|
|
2205
2360
|
forget: (key) => explained.delete(key),
|
|
2206
2361
|
mixedLine,
|
|
2362
|
+
listenerStatusFor,
|
|
2363
|
+
settlePresence,
|
|
2364
|
+
joinRoom,
|
|
2207
2365
|
callTool: (name, args = {}) =>
|
|
2208
2366
|
server._requestHandlers.get('tools/call')({ method: 'tools/call', params: { name, arguments: args } }, {}),
|
|
2209
2367
|
}
|
package/hive-notify.js
CHANGED
|
@@ -27,6 +27,7 @@ import crypto from 'crypto'
|
|
|
27
27
|
import { startSync, parseLink } from './sync.js'
|
|
28
28
|
import { makeNotifier, scrub } from './hive-webhook.js'
|
|
29
29
|
import { relayFetch } from './hive-transport.js'
|
|
30
|
+
import * as SEEN from './hive-seen.js' // what was already looked at, by Yjs id (0.14.4)
|
|
30
31
|
|
|
31
32
|
const oneLine = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t }
|
|
32
33
|
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
@@ -99,7 +100,14 @@ export function makeWatch({ hive, forNames, title, send, now = () => Date.now(),
|
|
|
99
100
|
const owners = hive.doc.getMap('owners')
|
|
100
101
|
const chat = hive.doc.getArray('chat')
|
|
101
102
|
const sent = loadSent(stateFile)
|
|
102
|
-
|
|
103
|
+
// SEEN BY IDENTITY, NOT BY COUNT (0.14.4). A count missed a late message: one a peer pushed while
|
|
104
|
+
// its update was in flight integrates BEHIND what was already counted, and `slice(seenChat)` never
|
|
105
|
+
// looked there, so a mention of someone away was never notified (hive-seen.js has the incident).
|
|
106
|
+
// A plain array (a test double) is append-only, so the count stays exact for it.
|
|
107
|
+
const ych = SEEN.isYArray(chat)
|
|
108
|
+
const seen = ych ? SEEN.makeFrontier() : null
|
|
109
|
+
if (ych) for (const { id } of SEEN.itemsOf(chat)) seen.add(id) // what was said before this started is not news
|
|
110
|
+
let seenChat = chat.length
|
|
103
111
|
const buffer = []
|
|
104
112
|
let lastFlush = 0
|
|
105
113
|
let lostSince = 0
|
|
@@ -142,13 +150,21 @@ export function makeWatch({ hive, forNames, title, send, now = () => Date.now(),
|
|
|
142
150
|
}
|
|
143
151
|
|
|
144
152
|
// MESSAGES NAMING THEM, from agents, while they are away: batched.
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
153
|
+
let fresh
|
|
154
|
+
if (ych) {
|
|
155
|
+
// A rebuilt room needs no special case: its messages carry ids this frontier has never seen.
|
|
156
|
+
fresh = []
|
|
157
|
+
for (const { id, value } of SEEN.itemsOf(chat)) { if (seen.has(id)) continue; seen.add(id); fresh.push(value) }
|
|
158
|
+
} else {
|
|
159
|
+
const all = chat.toArray()
|
|
160
|
+
if (all.length < seenChat) seenChat = 0 // the relay restarted and the room's history went with it
|
|
161
|
+
fresh = all.slice(seenChat)
|
|
162
|
+
seenChat = all.length
|
|
163
|
+
}
|
|
164
|
+
for (const m of fresh) {
|
|
148
165
|
if (!m || m.kind !== 'ai') continue
|
|
149
166
|
for (const p of people) if (addresses(m.text, p) && !here(p)) { buffer.push({ by: m.by, to: p, text: m.text }); break }
|
|
150
167
|
}
|
|
151
|
-
seenChat = all.length
|
|
152
168
|
if (buffer.length && t - lastFlush >= every * 60000) {
|
|
153
169
|
const shown = buffer.slice()
|
|
154
170
|
const to = [...new Set(shown.map((b) => b.to))].join(', ')
|