hivecode-mcp 0.11.2 → 0.11.3

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-coord.js CHANGED
@@ -31,8 +31,19 @@
31
31
 
32
32
  export function makeCoordinator(shared, self, opts = {}) {
33
33
  const ttl = opts.ttl != null ? opts.ttl : 5000
34
+ // A holder may RENEW its own claim, because a long edit should not lose the file mid-write.
35
+ // But renewal cannot be unbounded, or the lease stops being a lease. MEASURED on a 12-agent
36
+ // run: one agent re-claimed site/pricing.md eight times and legal/terms.md seven times and
37
+ // released twice, and since `claim` reset `at` on every call the 60s TTL never once elapsed.
38
+ // Six other agents sensed a live owner for the whole run; sixteen messages asked it to let go.
39
+ // `since` records the FIRST acquisition and survives renewal, so what caps a claim is total
40
+ // hold time rather than time since the holder last touched it.
41
+ const maxHold = opts.maxHold != null ? opts.maxHold : ttl * 3
34
42
  const now = opts.now || (() => Date.now())
35
- const expired = (c) => !c || (now() - c.at) >= c.ttl
43
+ const held = (c) => now() - (c.since != null ? c.since : c.at) // total, across renewals
44
+ const cap = (c) => (c.maxHold != null ? c.maxHold : maxHold)
45
+ const overheld = (c) => !!c && held(c) >= cap(c)
46
+ const expired = (c) => !c || (now() - c.at) >= c.ttl || overheld(c)
36
47
 
37
48
  // SENSE: the live, non-expired claim on a region (or null if open).
38
49
  const sense = (region) => { const c = shared.get(region); return expired(c) ? null : c }
@@ -40,7 +51,16 @@ export function makeCoordinator(shared, self, opts = {}) {
40
51
  // Is this region open to ME? (free, expired, or already mine)
41
52
  const free = (region) => { const c = shared.get(region); return expired(c) || c.by === self }
42
53
 
43
- const claim = (region, intent) => { shared.set(region, { by: self, intent, at: now(), ttl }) }
54
+ // A renewal keeps the original `since`; a fresh acquisition starts a new one. Claims written
55
+ // before `since` existed fall back to `at`, so an older room stays readable.
56
+ const claim = (region, intent) => {
57
+ const prev = shared.get(region)
58
+ const renewing = !!prev && prev.by === self && !expired(prev)
59
+ shared.set(region, {
60
+ by: self, intent, at: now(), ttl, maxHold,
61
+ since: renewing ? (prev.since != null ? prev.since : prev.at) : now(),
62
+ })
63
+ }
44
64
  const release = (region) => { const c = shared.get(region); if (c && c.by === self) shared.delete(region) }
45
65
 
46
66
  // ACQUIRE one region: sense → claim → verify. Returns true iff I now hold it.
@@ -49,6 +69,13 @@ export function makeCoordinator(shared, self, opts = {}) {
49
69
  const acquire = (region, intent) => {
50
70
  const c = shared.get(region)
51
71
  if (c && !expired(c) && c.by !== self) return false // sensed an owner → defer
72
+ // Once total hold is exhausted the region is open to everyone — INCLUDING the outgoing
73
+ // holder, which could then win the re-claim race indefinitely and starve the waiters that
74
+ // maxHold exists to admit. So the overrunning holder stands down for a full maxHold, which
75
+ // is a window every waiter is guaranteed to poll inside. A single-ttl cooldown was tried
76
+ // first and is NOT enough: the holder just waits it out and opens a fresh `since`.
77
+ // The bar lapses on its own, so a file nobody else wants returns to its owner.
78
+ if (c && c.by === self && overheld(c) && held(c) < 2 * cap(c)) return false
52
79
  claim(region, intent)
53
80
  const after = shared.get(region)
54
81
  return !(after && after.by !== self) // verify: did my claim survive?
package/hive-mailbox.js CHANGED
@@ -141,3 +141,33 @@ export function formatMessage(m) {
141
141
  const times = m.repeated > 1 ? ` (×${m.repeated})` : ''
142
142
  return `${m.at || ''} ${m.by}(${m.kind || 'ai'}): ${m.text}${times}`
143
143
  }
144
+
145
+ /**
146
+ * AMBIENT DIGEST -- what to do with room chatter nobody addressed to you.
147
+ *
148
+ * Un-addressed messages were handed to every agent in full, on every wake. In a room of N
149
+ * agents that means each one pays for the other N-1's conversation every single turn: O(N^2)
150
+ * context, so TOKEN COST -- not the relay -- becomes the real ceiling on room size. Measured:
151
+ * the relay holds 8 clients per room, converging in 1.0s on 45 MB. What actually stops a big
152
+ * room is this block of text.
153
+ *
154
+ * The COUNT stays exact and the newest messages still arrive in full. Only the older bodies
155
+ * are withheld, and the agent is told the number and how to read them, so nothing is hidden --
156
+ * it just stops being paid for unasked. An agent that wants the whole log calls hive_read_chat.
157
+ *
158
+ * Returns '' when there is nothing, so a caller can drop it with a falsy check.
159
+ */
160
+ export function ambientDigest(messages, { keep = 4 } = {}) {
161
+ const list = Array.isArray(messages) ? messages : []
162
+ if (!list.length) return ''
163
+ const shown = keep > 0 ? list.slice(-keep) : []
164
+ const hidden = list.length - shown.length
165
+ const head = 'room activity (' + list.length +
166
+ (hidden ? ', newest ' + shown.length + ' shown' : '') + '):'
167
+ const tail = hidden
168
+ ? '\n... ' + hidden + ' earlier message(s) withheld to keep your context small'
169
+ + ' -- hive_read_chat for the full log'
170
+ : ''
171
+ if (!shown.length) return head + tail
172
+ return head + '\n' + shown.map(formatMessage).join('\n') + tail
173
+ }
package/hive-mcp.js CHANGED
@@ -34,7 +34,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot
34
34
  if (!process.env.HIVE_PROVENANCE) process.env.HIVE_PROVENANCE = 'on'
35
35
  if (!process.env.HIVE_FORK_GATE) process.env.HIVE_FORK_GATE = 'on'
36
36
  import { startSync, parseLink } from './sync.js'
37
- import { createMailbox, formatMessage, mentionsName, priorityOf } from './hive-mailbox.js'
37
+ import { createMailbox, formatMessage, mentionsName, mentionedNames, priorityOf, ambientDigest } from './hive-mailbox.js'
38
38
 
39
39
  const DEFAULT_RELAY = process.env.HIVE_RELAY || 'wss://livecode-xoss.onrender.com'
40
40
  let session = null // { hive, room, relay, dir, name, mailbox }
@@ -457,7 +457,18 @@ async function dispatch(req) {
457
457
  // a human reading the log, not just to the recipient's tool output.
458
458
  const body = args.urgent && !/^\s*\[!\]/.test(raw) ? `[!] ${raw}` : raw
459
459
  requireSession().hive.say(body)
460
- return text(`sent: ${body}`)
460
+ // ADDRESSING IS THE PROTOCOL, and a rule an agent merely read is a rule it forgets once
461
+ // its context fills. So the tool itself answers back: a message that named nobody is
462
+ // reported as landing in everyone's ambient digest, where it is meant to be skipped.
463
+ // Immediate, per-call feedback, at the moment the mistake is made.
464
+ const named = mentionedNames(body)
465
+ if (!named.length) {
466
+ return text(`sent: ${body}\n\nNOBODY WAS ADDRESSED. This went to every agent's "room ` +
467
+ `activity", where they are told to skip it — so assume no one acts on it. Name the ` +
468
+ `agents you actually need with @Name (hive_members lists who is here). Use an ` +
469
+ `unaddressed message only for a genuine announcement to the whole room.`)
470
+ }
471
+ return text(`sent to ${named.map((n) => '@' + n).join(', ')}: ${body}`)
461
472
  }
462
473
  case 'hive_read_chat': {
463
474
  const me = requireSession().name
@@ -515,7 +526,8 @@ async function dispatch(req) {
515
526
  parts.push(got.mentions.map(formatMessage).join('\n'))
516
527
  }
517
528
  const ambient = got.messages.filter((m) => !mentionsName(m.text, s.name))
518
- if (ambient.length) parts.push(`room activity (${ambient.length}):\n` + ambient.map(formatMessage).join('\n'))
529
+ const dig = ambientDigest(ambient)
530
+ if (dig) parts.push(dig)
519
531
  if (tasks.length) parts.push(`approved work queued for you:\n${tasks.map(fmtTask).join('\n')}`)
520
532
  return text(parts.join('\n\n'))
521
533
  }
@@ -552,10 +564,10 @@ async function dispatch(req) {
552
564
  const mentionBlock = (r) => (r.mentions && r.mentions.length)
553
565
  ? `${r.priority === 'stop' ? '🛑 STOP — handle this BEFORE anything else' : '📣 ADDRESSED TO YOU'}:\n${r.mentions.map(formatMessage).join('\n')}`
554
566
  : ''
555
- const ambientBlock = (r) => {
556
- const rest = (r.messages || []).filter((m) => !mentionsName(m.text, me))
557
- return rest.length ? `room activity:\n${rest.map(formatMessage).join('\n')}` : ''
558
- }
567
+ // Both delivery paths go through the SAME digest. They used to format ambient traffic
568
+ // separately, which is how two copies of a rule drift apart -- the exact hazard that
569
+ // already bit the duplicated reconcile() and the two HIVE_RULES copies.
570
+ const ambientBlock = (r) => ambientDigest((r.messages || []).filter((m) => !mentionsName(m.text, me)))
559
571
 
560
572
  if (r && r.paused) {
561
573
  const parts = [mentionBlock(r), ambientBlock(r)].filter(Boolean)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hivecode-mcp",
3
- "version": "0.11.2",
3
+ "version": "0.11.3",
4
4
  "description": "MCP server that lets any AI coding agent join a Hivecode room \u2014 humans and multiple AI agents edit one live codebase with folder-scoped permissions and approval gates.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -88,7 +88,8 @@
88
88
  "demo:panel": "node demo/record-panel.mjs",
89
89
  "demo:all": "node demo/record.mjs && node demo/record-panel.mjs",
90
90
  "check:files": "node scripts/check-files.mjs",
91
- "prepublishOnly": "node scripts/check-files.mjs"
91
+ "prepublishOnly": "node scripts/check-files.mjs",
92
+ "demo:cast": "node demo/cast.mjs --dry-run"
92
93
  },
93
94
  "engines": {
94
95
  "node": ">=18"