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-receipts.js CHANGED
@@ -53,11 +53,18 @@
53
53
  // and exactly the shape that already bit this repo once as a duplicated reconcile(): two
54
54
  // descriptions of one thing, only one of which is ever checked against reality.
55
55
  import { mentionedNames, mentionsAll } from './hive-mailbox.js'
56
+ import { covers, makeFrontier } from './hive-seen.js'
56
57
 
57
58
  export const READ = 'READ'
58
59
  export const UNREAD = 'UNREAD'
59
60
  export const UNKNOWN = 'UNKNOWN'
60
61
  export const UNREACHABLE = 'UNREACHABLE'
62
+ // HANDED (0.14.4): the recipient's LISTENER took it and woke (or tried to wake) their app, but the
63
+ // agent itself has not read it. Before 0.14.4 the listener published the agent's read cursor, so this
64
+ // was reported as READ -- and an app that was never woken, or crashed first, lost the mail on its next
65
+ // session while the sender was told it had been read (Claude-cloud's attack 6, incident I-1).
66
+ // HANDED is not delivered: a listener holding a message is not an agent knowing it.
67
+ export const HANDED = 'HANDED'
61
68
 
62
69
  /** Only these count as delivered. Anything else is an absence of evidence, not evidence. */
63
70
  export const DELIVERED_STATES = [READ]
@@ -84,6 +91,31 @@ export function publishCursor(cursors, name, at, now = Date.now) {
84
91
  return rec
85
92
  }
86
93
 
94
+ /**
95
+ * Publish my read and handed FRONTIERS (hive-seen.js) into the room: record version 2.
96
+ *
97
+ * read advanced only by the agent's own hive_wait / hive_check / hive_read_chat
98
+ * handed advanced by its listener as well; a read is also handed, so read is merged into it
99
+ * at how many leading messages `read` covers without a gap. Kept so a peer on an older build,
100
+ * which only knows counts, is never told more than was read. It CAN go down, when a late
101
+ * message integrates inside that prefix, and that is the honest answer: no clamp here.
102
+ *
103
+ * Both frontiers only move forward (merged with what is already published). Writes nothing when
104
+ * nothing changed, for the same reason as publishCursor: an identical write wakes every observer.
105
+ */
106
+ export function publishFrontier(cursors, name, { read = null, handed = null, at = null, handedAt = '' } = {}, now = Date.now) {
107
+ if (!cursors || !name) return null
108
+ const prev = cursors.get(String(name))
109
+ const p = prev && prev.ver >= 2 ? prev : null
110
+ const r = makeFrontier(p && p.read); if (read) r.merge(read)
111
+ const h = makeFrontier(p && p.handed); if (handed) h.merge(handed); h.merge(r.toJSON())
112
+ const nextAt = at == null ? ((prev && Number(prev.at)) || 0) : Math.max(0, Number(at) || 0)
113
+ const rec = { ver: 2, at: nextAt, read: r.toJSON(), handed: h.toJSON(), handedAt: handed ? String(handedAt || '') : ((p && p.handedAt) || ''), ts: now() }
114
+ if (p && p.at === rec.at && JSON.stringify(p.read) === JSON.stringify(rec.read) && JSON.stringify(p.handed) === JSON.stringify(rec.handed)) return prev
115
+ cursors.set(String(name), rec)
116
+ return rec
117
+ }
118
+
87
119
  /** Case-insensitive, because @Builder and @builder are one agent everywhere else in this system. */
88
120
  export function cursorOf(cursors, name) {
89
121
  if (!cursors) return null
@@ -117,12 +149,26 @@ export function findIndex(chat, msg) {
117
149
  * Absence outranks a stale cursor for the same reason it does in hive-busy.js: it is the more
118
150
  * actionable of the two.
119
151
  */
120
- export function stateFor({ cursors, name, index, present = true, connected = true }) {
152
+ /**
153
+ * With a message's Yjs id and a version-2 record, the answer comes from the frontiers, so a message
154
+ * that integrated behind a reader's position is UNREAD, never a false READ. Without either, the count
155
+ * rule below still answers -- for a peer on an older build that only publishes counts.
156
+ */
157
+ function byFrontier(cur, id, name) {
158
+ if (!id || !cur || !(cur.ver >= 2) || !cur.read) return null
159
+ if (covers(cur.read, id)) return { name, state: READ, at: cur.ts, why: `@${name} has read it` }
160
+ if (covers(cur.handed, id)) return { name, state: HANDED, at: cur.ts, why: `@${name}'s listener took it${cur.handedAt ? ` at ${cur.handedAt}` : ''}, but ${name} has NOT read it yet` }
161
+ return null
162
+ }
163
+
164
+ export function stateFor({ cursors, name, index, id = null, present = true, connected = true }) {
121
165
  if (!connected) return { name, state: UNREACHABLE, why: 'this client is offline — the message is in your local copy only and has not left this machine' }
122
166
  if (!present) return { name, state: UNREACHABLE, why: `@${name} is not in the room, so nobody is going to read it` }
123
167
  if (index < 0) return { name, state: UNKNOWN, why: 'that message is not in the room log — it may not have synced' }
124
168
  const cur = cursorOf(cursors, name)
125
169
  if (!cur) return { name, state: UNKNOWN, why: `@${name} publishes no read position (an older build, a bridge, or a human client) — silence from them means nothing either way` }
170
+ const f = byFrontier(cur, id, name); if (f) return f
171
+ if (id && cur.ver >= 2 && cur.read) return { name, state: UNREAD, at: cur.ts, why: `@${name} has NOT been handed this one` }
126
172
  // A cursor of N means N messages consumed, i.e. indexes 0..N-1. So the message at `index` has
127
173
  // been handed over exactly when the cursor is strictly greater than it.
128
174
  if (Number(cur.at) > index) return { name, state: READ, at: cur.ts, why: `@${name} has read it` }
@@ -161,10 +207,15 @@ export function stateFor({ cursors, name, index, present = true, connected = tru
161
207
  * design argued in the room over four rounds. Tests are antigravity1's, written before the code
162
208
  * existed and verified red first: tests/transcript-receipts-test.mjs.
163
209
  */
164
- export function transcriptStateFor({ cursors, name, index, present = true }) {
210
+ export function transcriptStateFor({ cursors, name, index, id = null, present = true }) {
165
211
  if (index < 0) return { name, state: UNKNOWN, why: 'that message is not in the room log — it may not have synced' }
166
212
  const cur = cursorOf(cursors, name)
167
213
  if (!cur) return { name, state: UNKNOWN, why: `@${name} publishes no read position (an older build, a bridge, or a human client) — silence from them means nothing either way` }
214
+ const f = byFrontier(cur, id, name); if (f) return f
215
+ if (id && cur.ver >= 2 && cur.read) {
216
+ if (!present) return { name, state: UNREACHABLE, at: cur.ts, why: `@${name} is not in the room, and left without reading this message` }
217
+ return { name, state: UNREAD, at: cur.ts, why: `@${name} has NOT been handed this one` }
218
+ }
168
219
  // A cursor of N means N messages consumed, i.e. indexes 0..N-1. So the message at `index` has
169
220
  // been handed over exactly when the cursor is STRICTLY greater than it. A cursor sitting exactly
170
221
  // at `index` means NOT read; an off-by-one here is a receipt that lies quietly.
@@ -185,7 +236,7 @@ export const countDelivered = (states) => states.filter((s) => DELIVERED_STATES.
185
236
  * `@all` is skipped for the same reason a broadcast creates no reply debt in hive-mailbox.js --
186
237
  * one message obliging eight receipts is the storm, not the signal.
187
238
  */
188
- export function receipts({ chat, cursors, me, present = () => true, connected = true, limit = 10 }) {
239
+ export function receipts({ chat, ids = null, cursors, me, present = () => true, connected = true, limit = 10 }) {
189
240
  const list = Array.isArray(chat) ? chat : []
190
241
  const out = []
191
242
  for (let i = list.length - 1; i >= 0 && out.length < limit; i--) {
@@ -197,13 +248,13 @@ export function receipts({ chat, cursors, me, present = () => true, connected =
197
248
  out.push({
198
249
  at: m.at,
199
250
  text: String(m.text || '').slice(0, 120),
200
- to: named.map((n) => stateFor({ cursors, name: n, index: i, present: present(n), connected })),
251
+ to: named.map((n) => stateFor({ cursors, name: n, index: i, id: ids ? ids[i] : null, present: present(n), connected })),
201
252
  })
202
253
  }
203
254
  return out.reverse()
204
255
  }
205
256
 
206
- const MARK = { [READ]: '✓', [UNREAD]: '…', [UNKNOWN]: '?', [UNREACHABLE]: '✗' }
257
+ const MARK = { [READ]: '✓', [HANDED]: '↪', [UNREAD]: '…', [UNKNOWN]: '?', [UNREACHABLE]: '✗' }
207
258
 
208
259
  /** One line per message. The marks make a column a human can scan; the words carry the meaning. */
209
260
  export function describe(r) {
package/hive-room-mode.js CHANGED
@@ -23,6 +23,7 @@ import crypto from 'crypto'
23
23
  import { spawnSync } from 'child_process'
24
24
  import { parseLink, startSync } from './sync.js'
25
25
  import { landAndOpen, testCommandFor } from './hive-land.js'
26
+ import * as SEEN from './hive-seen.js' // what the room held when the issue was posted, by Yjs id (0.14.4)
26
27
 
27
28
  export const MIN_ROOM_MODE_VERSION = '0.13.0'
28
29
 
@@ -709,6 +710,17 @@ export async function postIssue(opts = {}) {
709
710
  // 3. Post chat message
710
711
  const chatText = formatIssueChat({ issue, waysOfWorking })
711
712
  if (hive.say) hive.say(chatText)
713
+ // Everything this poster held once its own post was in: the watcher counts an agent reply as
714
+ // "since the issue was posted" when its id is NOT in here, wherever it integrates (0.14.4).
715
+ const postFrontier = (() => {
716
+ try {
717
+ const ch = hive.doc && hive.doc.getArray('chat')
718
+ if (!SEEN.isYArray(ch)) return null
719
+ const f = SEEN.makeFrontier()
720
+ for (const { id } of SEEN.itemsOf(ch)) f.add(id)
721
+ return f.toJSON()
722
+ } catch { return null }
723
+ })()
712
724
 
713
725
  // 4. Verify delivery via fresh passive peek
714
726
  const chatMarker = attempt > 1
@@ -739,6 +751,7 @@ export async function postIssue(opts = {}) {
739
751
  verified: true,
740
752
  members: members.agents,
741
753
  comment,
754
+ postFrontier,
742
755
  }
743
756
  } finally {
744
757
  if (hive && typeof hive.stop === 'function') {
@@ -22,6 +22,7 @@
22
22
  import fs from 'fs'
23
23
  import os from 'os'
24
24
  import path from 'path'
25
+ import * as SEEN from './hive-seen.js' // replies since the post, by Yjs id (0.14.4)
25
26
  import { startSync, parseLink } from './sync.js'
26
27
  import * as ACT from './hive-activity.js'
27
28
  import { LABEL, HUMAN_LABEL, PUSHERS } from './hive-github.js'
@@ -193,7 +194,7 @@ export function makeRoomWatcher(o) {
193
194
  log(`#${n}: withdrawn`)
194
195
  return
195
196
  }
196
- const peek = await deps.peek({ issueNumber: n, taskId: a.taskId, since: a.postedAt })
197
+ const peek = await deps.peek({ issueNumber: n, taskId: a.taskId, since: a.postedAt, postFrontier: a.postFrontier || null })
197
198
  if (peek && peek.ok && peek.engaged && !a.engagedAt) a.engagedAt = now()
198
199
 
199
200
  // A LONE AGENT CANNOT GET A SECOND AGENT'S REVIEW. When the work is done and the only thing
@@ -395,7 +396,7 @@ export function makeRoomWatcher(o) {
395
396
  return { postFailed: n }
396
397
  }
397
398
  it.status = 'active'
398
- state.active = { n, taskId: p.taskId, postedAt: now(), engagedAt: 0, runs: 0, fallback: null, lastFailedFingerprint: '' }
399
+ state.active = { n, taskId: p.taskId, postedAt: now(), postFrontier: p.postFrontier || null, engagedAt: 0, runs: 0, fallback: null, lastFailedFingerprint: '' }
399
400
  // Commands already on the issue (a "/hivecode stop" from before this run) are history: only
400
401
  // what is said from now on stops it.
401
402
  try { await commands(n, 0) } catch { /* read again next tick */ }
@@ -583,7 +584,30 @@ export async function runRoomMode({ f, opt, gh, id, info, root, gitDir, label, l
583
584
  }
584
585
 
585
586
  /** A passive look at the room: who can hear, and whether anyone has engaged with this issue. */
586
- export async function peekRoom({ link, issueNumber, taskId, since = 0, token = '', timeoutMs = 60000 }) {
587
+ /**
588
+ * Has an agent said something about this issue since the room's post of it?
589
+ *
590
+ * BY IDENTITY WHEN WE CAN (0.14.4). "After the post" by POSITION missed an agent reply that was in
591
+ * flight when the issue was posted: it integrates BEFORE the post and read as "nobody replied". With
592
+ * the frontier the poster held right after posting (postIssue returns it), a reply counts if its Yjs id
593
+ * is not in it, wherever it landed. A run posted by an older build has no frontier and keeps the
594
+ * position rule: lines after the LAST post of this issue (a retry is "Issue #12 (attempt 2): ...").
595
+ */
596
+ export function agentRepliedSince({ chat, issueNumber, taskId = '', postFrontier = null }) {
597
+ const ref = new RegExp(`#${Number(issueNumber)}(?!\\d)`)
598
+ const isAgent = (m) => m && m.kind !== 'human' && !/^GitHub/.test(String(m.by || ''))
599
+ const replies = (m) => isAgent(m) && (ref.test(String(m.text || '')) || (taskId && String(m.text || '').includes(taskId)))
600
+ if (postFrontier && typeof postFrontier === 'object' && SEEN.isYArray(chat)) {
601
+ return SEEN.itemsOf(chat).some(({ id, value }) => !SEEN.covers(postFrontier, id) && replies(value))
602
+ }
603
+ const list = SEEN.isYArray(chat) ? chat.toArray() : (Array.isArray(chat) ? chat : [])
604
+ const posted = new RegExp(`Issue #${Number(issueNumber)}(?: \\(attempt \\d+\\))?:`)
605
+ let from = -1
606
+ for (let i = list.length - 1; i >= 0; i--) if (/^GitHub/.test(String(list[i].by || '')) && posted.test(String(list[i].text || ''))) { from = i; break }
607
+ return from >= 0 && list.slice(from + 1).some(replies)
608
+ }
609
+
610
+ export async function peekRoom({ link, issueNumber, taskId, since = 0, postFrontier = null, token = '', timeoutMs = 60000 }) {
587
611
  const { relay, room } = parseLink(link)
588
612
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-room-peek-'))
589
613
  const peek = startSync({ relay, room, dir, name: 'GitHub-peek', kind: 'ai', passive: true, syncFiles: false, token, log: () => {} })
@@ -598,15 +622,9 @@ export async function peekRoom({ link, issueNumber, taskId, since = 0, token = '
598
622
  const members = peek.members().filter((m) => m && m.name && !/^GitHub/.test(m.name) && m.kind !== 'human' && !m.passive)
599
623
  const reachable = members.filter((m) => ACT.describe(activity.get(m.name), { present: true, listener: m.listener, app: m.app, now: Date.now() }).reachable).map((m) => m.name)
600
624
  const chat = peek.doc.getArray('chat').toArray()
601
- // Chat times are clock strings ("21:04:39") with no date, so "since the issue was posted" is
602
- // told by POSITION: lines after the room's own post of this issue.
603
- const ref = new RegExp(`#${Number(issueNumber)}(?!\\d)`)
604
- const isAgent = (m) => m && m.kind !== 'human' && !/^GitHub/.test(String(m.by || ''))
605
- let from = -1
606
- // The LAST post of this issue: a retry is posted as "Issue #12 (attempt 2): ...".
607
- const posted = new RegExp(`Issue #${Number(issueNumber)}(?: \\(attempt \\d+\\))?:`)
608
- for (let i = chat.length - 1; i >= 0; i--) if (/^GitHub/.test(String(chat[i].by || '')) && posted.test(String(chat[i].text || ''))) { from = i; break }
609
- const said = from >= 0 && chat.slice(from + 1).some((m) => isAgent(m) && (ref.test(String(m.text || '')) || (taskId && String(m.text || '').includes(taskId))))
625
+ // Chat times are clock strings ("21:04:39") with no date, so "since the issue was posted" is told
626
+ // by Yjs identity when the post's frontier is known, else by position (agentRepliedSince).
627
+ const said = agentRepliedSince({ chat: peek.doc.getArray('chat'), issueNumber, taskId, postFrontier })
610
628
  const t = peek.doc.getMap('tasks').get(taskId)
611
629
  const claimed = [...peek.doc.getMap('claims').values()].some((c) => c && c.by && Number(c.since || c.at || 0) >= since)
612
630
  return {
package/hive-seen.js ADDED
@@ -0,0 +1,135 @@
1
+ // hive-seen.js -- which chat messages has this reader seen? Answered by IDENTITY, never by position.
2
+ //
3
+ // THE DEFECT THIS EXISTS FOR (9 Nov 2026, found live in room-6UlP…; reproduced by Opus-Cloud and
4
+ // Claude-cloud on real Yjs at d5b6f56, urgent mail missed in 15 of 40 trials). Every "how far have I
5
+ // read" marker in Hivecode was a COUNT or an ARRAY POSITION: the mailbox behind hive_wait/hive_check
6
+ // and the [!] gate, the room read cursors behind hive_delivered, hive-await's cursor file, the stop
7
+ // hook's watermark, hive-notify, the room watcher, the Slack bridge. But a Y.Array is not append-only.
8
+ // A peer that pushed while its update was still in flight (offline, a stuck long-poll outbox, a crash
9
+ // and rejoin) carries its own clock, and Yjs orders concurrent inserts by (clientID, clock), not by
10
+ // arrival -- so the late message integrates BEHIND messages everyone already read. A position reader
11
+ // never looks there again: it re-delivers a shifted old message and silently drops the new one.
12
+ // antigravity-VM's "@Opus-Cloud … env variables" was stamped 10:22, reached the relay at 11:05 and
13
+ // landed at #818, behind 13 messages the whole room had read. Nobody's reader delivered it.
14
+ //
15
+ // THE IDENTITY. Every element of a Y.Array has a global id {client, clock}: the client that inserted
16
+ // it and that client's clock at the insert. It never changes, wherever the element ends up.
17
+ //
18
+ // "SEEN" IS A FRONTIER: per client, the HIGHEST clock this reader has seen. An id is seen iff
19
+ // frontier[client] >= clock. A late element from a client carries a clock above that client's
20
+ // frontier, so it is unseen wherever Yjs places it. A frontier is exact because one client's inserts
21
+ // into one array integrate in clock order: Yjs never surfaces clock k+1 of a client before clock k.
22
+ //
23
+ // NOT A STATE VECTOR. Yjs's own state vector (Y.encodeStateVector) stores the NEXT clock it expects,
24
+ // highest + 1. This stores the highest SEEN. Building one from the other is off by one and drops
25
+ // exactly one message per client, silently -- so nothing here reads or writes a Yjs state vector, and
26
+ // tests/mail-identity-test.mjs pins the difference. (Claude-cloud's review, attack 2.)
27
+ //
28
+ // PRUNED. Every MCP restart is a new Y.Doc clientID, so a long-lived room accumulates writers. A
29
+ // frontier is pruned to clients that still have an undeleted element in the array. That only ever
30
+ // costs a re-read, never a skip: a later element from a pruned client is unseen and is delivered.
31
+ //
32
+ // THE ONE INTERNAL. `itemsOf` walks the array's item list (`yarr._start`, `item.right`) because Yjs
33
+ // has no public API that pairs a value with its id. yjs is pinned in package.json, and the test
34
+ // asserts `itemsOf(a)` yields exactly `a.toArray()` in order, so a yjs change that breaks the walk
35
+ // fails CI instead of silently mis-reading mail.
36
+
37
+ /** Is this a Y.Array (or anything with Yjs item structure), as opposed to a plain JS array? */
38
+ export function isYArray(x) {
39
+ return !!x && !Array.isArray(x) && typeof x.toArray === 'function' && '_start' in x
40
+ }
41
+
42
+ /**
43
+ * The array's elements in order, each with its Yjs id: [{ id: { client, clock }, value }].
44
+ * Deleted elements are skipped, as toArray() skips them. Element k of an item carries clock + k.
45
+ */
46
+ export function itemsOf(yarr) {
47
+ const out = []
48
+ if (!isYArray(yarr)) return out
49
+ for (let it = yarr._start; it; it = it.right) {
50
+ if (it.deleted || !it.countable) continue
51
+ const vals = it.content.getContent()
52
+ for (let k = 0; k < vals.length; k++) out.push({ id: { client: it.id.client, clock: it.id.clock + k }, value: vals[k] })
53
+ }
54
+ return out
55
+ }
56
+
57
+ /** The clients that still have an undeleted element in the array. */
58
+ export function liveClients(yarr) {
59
+ const s = new Set()
60
+ for (const { id } of itemsOf(yarr)) s.add(id.client)
61
+ return s
62
+ }
63
+
64
+ /** id as a short string, "client:clock" -- what HIVE_CHAT.md carries for readers without Yjs. */
65
+ export const idKey = (id) => `${id.client}:${id.clock}`
66
+ export function parseIdKey(s) {
67
+ const m = /^(\d+):(\d+)$/.exec(String(s || '').trim())
68
+ return m ? { client: Number(m[1]), clock: Number(m[2]) } : null
69
+ }
70
+
71
+ /** Does a serialized frontier ({ "client": clock }) cover this id? Absent or malformed covers nothing. */
72
+ export function covers(json, id) {
73
+ if (!json || typeof json !== 'object' || !id) return false
74
+ const v = json[String(id.client)]
75
+ return Number.isFinite(Number(v)) && Number(v) >= id.clock
76
+ }
77
+
78
+ /**
79
+ * A mutable frontier. `version` counts real advances, so a publisher can tell "nothing new" from
80
+ * "something new" without comparing two objects.
81
+ */
82
+ export function makeFrontier(json = null) {
83
+ const m = new Map()
84
+ let version = 0
85
+ const add = (id) => {
86
+ if (!id || !Number.isFinite(id.clock)) return false
87
+ const cur = m.has(id.client) ? m.get(id.client) : -1
88
+ if (id.clock <= cur) return false
89
+ m.set(id.client, id.clock); version++
90
+ return true
91
+ }
92
+ const merge = (other) => {
93
+ if (!other || typeof other !== 'object') return false
94
+ let changed = false
95
+ for (const [c, v] of Object.entries(other)) {
96
+ const client = Number(c), clock = Number(v)
97
+ if (Number.isFinite(client) && Number.isFinite(clock) && add({ client, clock })) changed = true
98
+ }
99
+ return changed
100
+ }
101
+ if (json) merge(json)
102
+ return {
103
+ has: (id) => !!id && m.has(id.client) && m.get(id.client) >= id.clock,
104
+ add,
105
+ merge,
106
+ get version() { return version },
107
+ get size() { return m.size },
108
+ /** Serialize. With `prune` (a Y.Array), keep only clients that still have an element in it. */
109
+ toJSON({ prune = null } = {}) {
110
+ const keep = prune ? liveClients(prune) : null
111
+ const o = {}
112
+ for (const [c, v] of m) if (!keep || keep.has(c)) o[String(c)] = v
113
+ return o
114
+ },
115
+ }
116
+ }
117
+
118
+ /** The frontier that covers the array's first n elements -- the one-time migration from a count. */
119
+ export function frontierOfPrefix(yarr, n) {
120
+ const f = makeFrontier()
121
+ const items = itemsOf(yarr)
122
+ for (let i = 0; i < Math.min(Math.max(0, Number(n) || 0), items.length); i++) f.add(items[i].id)
123
+ return f
124
+ }
125
+
126
+ /**
127
+ * How many leading elements a frontier covers without a gap. Published beside the frontier as `at`,
128
+ * so a peer on an older build -- which only knows counts -- is never told more than was really read.
129
+ * It can go DOWN when a late element integrates inside the prefix, and that is the honest answer.
130
+ */
131
+ export function coveredPrefix(yarr, json) {
132
+ let n = 0
133
+ for (const { id } of itemsOf(yarr)) { if (!covers(json, id)) break; n++ }
134
+ return n
135
+ }
package/hive-slack.js CHANGED
@@ -31,6 +31,7 @@ import path from 'path'
31
31
  import WebSocket from 'ws'
32
32
  import { startSync, parseLink } from './sync.js'
33
33
  import { scrub } from './hive-webhook.js'
34
+ import * as SEEN from './hive-seen.js' // what was already forwarded, by Yjs id (0.14.4)
34
35
  import { realpathSync as __hiveRealpath } from 'fs'
35
36
  import { resolve as __hiveResolve } from 'path'
36
37
  import { fileURLToPath as __hiveFileURLToPath } from 'url'
@@ -38,6 +39,25 @@ import { fileURLToPath as __hiveFileURLToPath } from 'url'
38
39
  const DEFAULT_API = 'https://slack.com/api'
39
40
  const ROOM_THREAD_KEY = '__room__'
40
41
  const CURSOR_KEY = '__cursor__'
42
+
43
+ /**
44
+ * The room->Slack observer: hands `onChat` every chat message not yet forwarded, by Yjs id, wherever it
45
+ * integrated (0.14.4; see runBridge for why not by count). `forwarded` is a Y.Map of frontiers keyed by
46
+ * channel, `at` the old count cursor (used to seed a frontier once, when none was saved), `threads` the
47
+ * map that keeps that count for older bridges. Returns the function to pass to chat.observe.
48
+ */
49
+ export function makeChatForwarder({ chat, forwarded, key, at, threads, onChat }) {
50
+ const saved = forwarded.get(key)
51
+ const fwd = saved && typeof saved === 'object' ? SEEN.makeFrontier(saved) : SEEN.frontierOfPrefix(chat, at)
52
+ return () => {
53
+ const fresh = []
54
+ for (const { id, value } of SEEN.itemsOf(chat)) { if (fwd.has(id)) continue; fwd.add(id); fresh.push(value) }
55
+ if (!fresh.length) return
56
+ onChat(fresh)
57
+ try { forwarded.set(key, fwd.toJSON({ prune: chat })) } catch { }
58
+ try { const n = SEEN.coveredPrefix(chat, fwd.toJSON()); if (n > (Number(threads.get(CURSOR_KEY)) || 0)) threads.set(CURSOR_KEY, n) } catch { }
59
+ }
60
+ }
41
61
  const MAX_SEEN_EVENTS = 500
42
62
 
43
63
  export const USAGE = `usage:
@@ -2396,8 +2416,13 @@ export async function main(argv = process.argv.slice(2), { log = console.log, lo
2396
2416
  }
2397
2417
  const chat = hive.doc.getArray('chat')
2398
2418
  const savedCursor = Number(bridge.threads.get(CURSOR_KEY)) || 0
2399
- let at = savedCursor > 0 && savedCursor <= chat.length ? savedCursor : chat.length
2400
- chat.observe(() => { const all = chat.toArray(); bridge.onChat(all.slice(at), at); at = all.length })
2419
+ const at = savedCursor > 0 && savedCursor <= chat.length ? savedCursor : chat.length
2420
+ // FORWARDED BY IDENTITY, NOT BY COUNT (0.14.4). `all.slice(at)` never looked behind `at`, so a message
2421
+ // a peer pushed while its update was in flight -- which integrates BEHIND what was already counted --
2422
+ // was never sent to Slack (hive-seen.js has the incident). What was forwarded is now a frontier of
2423
+ // Yjs ids, kept in its own room map: the thread map's other keys are Slack thread ids, and an older
2424
+ // bridge would try to poll any new key there as a thread. The count stays beside it, for those.
2425
+ chat.observe(makeChatForwarder({ chat, forwarded: hive.doc.getMap('slack_forwarded'), key: String(CHANNEL), at, threads: bridge.threads, onChat: bridge.onChat }))
2401
2426
  await bridge.watchRoom()
2402
2427
  log(`READY: [hive-slack] @${pre.botUser} bridging ${room} -> #${pre.channelName} (${CHANNEL}) — resume: ${allowParsed.ids.length ? allowParsed.ids.join(', ') : 'off'}`)
2403
2428
  return 0
package/hive-stop-hook.js CHANGED
@@ -55,6 +55,7 @@
55
55
  */
56
56
  import fs from 'fs'
57
57
  import path from 'path'
58
+ import crypto from 'crypto'
58
59
  import { mentionsName, unansweredFor } from './hive-mailbox.js'
59
60
  import { isArmed, listenCommand, listenHow, listenOrder } from './hive-listen.js'
60
61
  import { readAgents, agentForHook } from './hive-session.js'
@@ -128,32 +129,46 @@ export function decide(o) {
128
129
  return { ...d, block: true, reason: d.block ? `${d.reason}\n\n${listenLine}` : listenLine, listenBlocks: listenBlocks + 1 }
129
130
  }
130
131
 
131
- function obligations({ chat, tasks, me, seen, taskBlocks, nudged = [] }) {
132
+ // WHICH MENTIONS WERE ALREADY BLOCKED ON, BY IDENTITY, NOT BY LINE (0.14.4). The watermark was a line
133
+ // count, `seen`: everything after line N was new. But the room's chat is a Y.Array, and a message a
134
+ // peer pushed while its update was in flight integrates at its ORIGINAL position, behind lines already
135
+ // counted -- so a late mention was rendered mid-file and this hook never blocked on it (hive-seen.js
136
+ // has the incident). This file has no Yjs, so a mention is identified by what HIVE_CHAT.md carries:
137
+ // author, time and text. The only messages that can share that are exact duplicates -- the same author
138
+ // saying the same words in the same second -- and blocking once for both is the right answer anyway.
139
+ const fingerprint = (m) => crypto.createHash('sha1').update(`${m.by}\u0000${m.at}\u0000${m.text}`).digest('hex').slice(0, 16)
140
+
141
+ function obligations({ chat, tasks, me, seen, handled = null, taskBlocks, nudged = [] }) {
132
142
  const msgs = parseChat(chat)
133
143
  const nowSeen = msgs.length
134
- // A cursor past the end means this is a different (rebuilt or switched) room -- start over
135
- // rather than treating the whole history as unread and blocking on all of it.
136
- const from = seen > nowSeen ? 0 : seen
137
- const fresh = msgs.slice(from)
138
-
139
- const mail = fresh.filter((m) =>
144
+ const isMail = (m) =>
140
145
  m.kind !== 'system' &&
141
146
  String(m.by).toLowerCase() !== String(me).toLowerCase() && // never block on our own post
142
- mentionsName(m.text, me))
147
+ mentionsName(m.text, me)
148
+ // `handled` (0.14.4): the fingerprints of every mention already blocked on. A state written by an
149
+ // older build has only the count; it is migrated once, as the prefix it described. A count past the
150
+ // end means a different (rebuilt or switched) room: start over rather than block on its history.
151
+ let done
152
+ if (Array.isArray(handled)) done = new Set(handled)
153
+ else done = new Set(msgs.slice(0, seen > nowSeen ? 0 : seen).filter(isMail).map(fingerprint))
154
+ const mail = msgs.filter((m) => isMail(m) && !done.has(fingerprint(m)))
155
+ // After this decision every mention in the file is handled: blocked on now, or before. Kept to what
156
+ // the file holds, so it is bounded by the room's history and a rebuilt room sheds the old ones.
157
+ const nextHandled = [...new Set(msgs.filter(isMail).map(fingerprint))]
143
158
 
144
159
  if (mail.length) {
145
160
  const quoted = mail.slice(0, 5).map((m) => ` ${m.by}: ${m.text.replace(/\s+/g, ' ').slice(0, 220)}`)
146
161
  const more = mail.length > 5 ? `\n ... and ${mail.length - 5} more` : ''
147
162
  return {
148
- block: true, seen: nowSeen, taskBlocks: 0,
163
+ block: true, seen: nowSeen, handled: nextHandled, taskBlocks: 0,
149
164
  // Blocking here also spends this message's ONE nudge, so the debt branch below cannot
150
165
  // charge for the same message a second time. One block per message, whatever the reason.
151
166
  nudged: [...nudged, ...mail.map((m) => m.by + '|' + m.at)].slice(-200),
152
167
  reason: `You are in a Hivecode room and ${mail.length} message${mail.length > 1 ? 's' : ''} ` +
153
168
  `addressed to you arrived while you were working:\n\n${quoted.join('\n')}${more}\n\n` +
154
169
  `Do not stop -- they are waiting on a reply from you. Answer with hive_say (put @Name on ` +
155
- `it), then go back to hive_wait. hive_wait is your idle state; ending your turn makes you ` +
156
- `unreachable and nobody in the room finds out.`,
170
+ `it), then stay reachable the way this client does it (your listener, or hive_wait): a ` +
171
+ `participant who stops hearing still looks present, and nobody in the room finds out.`,
157
172
  }
158
173
  }
159
174
 
@@ -172,7 +187,7 @@ function obligations({ chat, tasks, me, seen, taskBlocks, nudged = [] }) {
172
187
  if (unnudged.length) {
173
188
  const quoted = unnudged.slice(0, 3).map((o) => ` ${o.from}: ${o.text.replace(/\s+/g, ' ').slice(0, 200)}`)
174
189
  return {
175
- block: true, seen: nowSeen, taskBlocks,
190
+ block: true, seen: nowSeen, handled: nextHandled, taskBlocks,
176
191
  // Recorded BEFORE returning, exactly as the watermark is, so a question cannot block a
177
192
  // second time even if whatever happens next goes wrong. Capped so the state file cannot
178
193
  // grow without bound in a long-lived room.
@@ -181,7 +196,7 @@ function obligations({ chat, tasks, me, seen, taskBlocks, nudged = [] }) {
181
196
  `still waiting on a reply from you:\n\n${quoted.join('\n')}\n\n` +
182
197
  `Answer with hive_say and name them. "Not yet — I am on X, I will come back to you" is a ` +
183
198
  `complete answer; silence is the one thing they cannot act on, and nothing tells them you ` +
184
- `stopped. Then call hive_wait.`,
199
+ `stopped. Then stay reachable (your listener, or hive_wait).`,
185
200
  }
186
201
  }
187
202
 
@@ -189,11 +204,11 @@ function obligations({ chat, tasks, me, seen, taskBlocks, nudged = [] }) {
189
204
  if (open.length && taskBlocks < MAX_TASK_BLOCKS) {
190
205
  const quoted = open.slice(0, 3).map((t) => ` [${t.id}] from ${t.from}: ${t.text.replace(/\s+/g, ' ').slice(0, 220)}`)
191
206
  return {
192
- block: true, seen: nowSeen, taskBlocks: taskBlocks + 1, nudged,
207
+ block: true, seen: nowSeen, handled: nextHandled, taskBlocks: taskBlocks + 1, nudged,
193
208
  reason: `You are in a Hivecode room with ${open.length} accepted task${open.length > 1 ? 's' : ''} ` +
194
209
  `still open:\n\n${quoted.join('\n')}\n\n` +
195
210
  `Finish the work and call hive_complete with the task id, or say why you are blocked with ` +
196
- `hive_say (@ the person who assigned it). Then call hive_wait. If you genuinely cannot ` +
211
+ `hive_say (@ the person who assigned it). Then stay reachable. If you genuinely cannot ` +
197
212
  `proceed, say so in the room first -- do not just stop, nobody would learn that you had.`,
198
213
  }
199
214
  }
@@ -202,7 +217,7 @@ function obligations({ chat, tasks, me, seen, taskBlocks, nudged = [] }) {
202
217
  // as it being resolved, so hold the counter rather than resetting it while it is still there.
203
218
  // Debts and tasks share the counter deliberately: they are the same kind of obligation, and
204
219
  // two separate counters would let an agent be pinned for 2x MAX_TASK_BLOCKS turns.
205
- return { block: false, seen: nowSeen, taskBlocks: open.length ? taskBlocks : 0, nudged }
220
+ return { block: false, seen: nowSeen, handled: nextHandled, taskBlocks: open.length ? taskBlocks : 0, nudged }
206
221
  }
207
222
 
208
223
  /** Walk up from `start` looking for the session file; the hook's cwd is usually but not always
@@ -266,7 +281,7 @@ async function main() {
266
281
 
267
282
  const d = decide({
268
283
  chat, tasks: read('HIVE_TASKS.md'), me: session.name,
269
- seen: Number(st.seen) || 0, taskBlocks: Number(st.taskBlocks) || 0,
284
+ seen: Number(st.seen) || 0, handled: Array.isArray(st.handled) ? st.handled : null, taskBlocks: Number(st.taskBlocks) || 0,
270
285
  nudged: Array.isArray(st.nudged) ? st.nudged : [],
271
286
  listening, listenLine, listenBlocks: Number(st.listenBlocks) || 0,
272
287
  })
@@ -274,7 +289,7 @@ async function main() {
274
289
  // GUARD 2: write the watermark BEFORE returning the block, so this message can never block a
275
290
  // second time even if what happens next goes wrong.
276
291
  try {
277
- prev[key] = { seen: d.seen, taskBlocks: d.taskBlocks, nudged: d.nudged || [], listenBlocks: d.listenBlocks, at: Date.now() }
292
+ prev[key] = { seen: d.seen, handled: d.handled, taskBlocks: d.taskBlocks, nudged: d.nudged || [], listenBlocks: d.listenBlocks, at: Date.now() }
278
293
  fs.writeFileSync(statePath, JSON.stringify(prev, null, 2))
279
294
  } catch { /* a watermark we cannot persist is a reason to allow, never to fail */ }
280
295
 
package/hive-transport.js CHANGED
@@ -24,7 +24,8 @@
24
24
  // set when node started, so the WebSocket died with ENOTFOUND, the long-poll probe died the same
25
25
  // way, and hive_join said "the relay refused the connection" about a relay it never reached. Now:
26
26
  // - proxyFor(url) picks the proxy: HIVE_PROXY (a URL, or off), else HTTPS_PROXY for wss:/https:
27
- // and HTTP_PROXY for ws:/http:. NO_PROXY is honoured; loopback never goes through a proxy.
27
+ // and HTTP_PROXY for ws:/http:. NO_PROXY is honoured; loopback never goes through a proxy,
28
+ // including where node's env-proxy mode would send it there (directFetch).
28
29
  // - Both pipes ride an HTTP CONNECT tunnel through it: the WebSocket (an agent for `ws`) and the
29
30
  // long-poll requests (fetchFor). Through a tunnel to a wss:// relay the proxy sees only
30
31
  // "CONNECT host:443"; the path and query -- where the room token rides -- stay inside TLS.
@@ -184,13 +185,46 @@ function tunnelAgent(proxy, secure, kind = 'fetch') {
184
185
 
185
186
  /** A fetch-shaped call carried over the tunnel agent: just what offersPoll and PollSocket use. */
186
187
  function proxyFetch(proxy, url, init = {}) {
188
+ return agentFetch(url, init, (secure) => tunnelAgent(proxy, secure))
189
+ }
190
+
191
+ // DIRECT MEANS DIRECT, EVEN WHERE NODE APPLIES THE ENV PROXY ITSELF. With env-proxy mode on
192
+ // (NODE_USE_ENV_PROXY=1 or --use-env-proxy, what Antigravity's VMs run), the built-in fetch AND http's
193
+ // global agent send every request through http(s)_proxy unless node's own NO_PROXY exempts it. So a
194
+ // call proxyFor() had decided was direct -- loopback, a NO_PROXY match, HIVE_PROXY=off -- went to the
195
+ // proxy anyway: a self-hosted relay on localhost was "unreachable" for an invite revoke or the /health
196
+ // probe while its WebSockets worked (antigravity-VM, 9 Nov 2026; tests/loopback-direct-fetch-test.mjs).
197
+ // Measured on node 22.23 and 24.22: an agent of our own is never proxied. Used only in env-proxy mode,
198
+ // so everywhere else a direct call is still the built-in fetch, exactly as before.
199
+ // Node fixes the mode ONCE, AT STARTUP: clearing NODE_USE_ENV_PROXY later does not turn it off (a
200
+ // first version read it per call, and proxy-fetch-test, which clears it mid-run, still went to the
201
+ // proxy). So the mode is read when this module loads -- the nearest point to node's own decision --
202
+ // and a per-call env can only add it, never take it away.
203
+ const envProxyIn = (env) => {
204
+ if (/^(1|true|yes|on)$/i.test(String((env && env.NODE_USE_ENV_PROXY) || '').trim())) return true
205
+ return /(^|\s)--use-env-proxy(\s|=|$)/.test(String((env && env.NODE_OPTIONS) || ''))
206
+ }
207
+ const ENV_PROXY_AT_START = envProxyIn(process.env) || process.execArgv.includes('--use-env-proxy')
208
+ const envProxyMode = (env = process.env) => ENV_PROXY_AT_START || envProxyIn(env)
209
+ const directAgents = new Map()
210
+ function directAgent(secure) {
211
+ let a = directAgents.get(secure)
212
+ if (!a) { a = new (secure ? https.Agent : http.Agent)({ keepAlive: true, maxFreeSockets: 4 }); directAgents.set(secure, a) }
213
+ return a
214
+ }
215
+ function directFetch(url, init = {}) {
216
+ return agentFetch(url, init, directAgent)
217
+ }
218
+
219
+ /** The request behind proxyFetch and directFetch: `agentFor(secure)` picks the agent that carries it. */
220
+ function agentFetch(url, init, agentFor) {
187
221
  return new Promise((resolve, reject) => {
188
222
  const u = new URL(url)
189
223
  const secure = u.protocol === 'https:'
190
224
  const body = init.body == null ? null : Buffer.from(init.body)
191
225
  const headers = { ...(init.headers || {}) }
192
226
  if (body) headers['content-length'] = String(body.length)
193
- const req = (secure ? https : http).request(u, { method: init.method || 'GET', headers, agent: tunnelAgent(proxy, secure) }, (res) => {
227
+ const req = (secure ? https : http).request(u, { method: init.method || 'GET', headers, agent: agentFor(secure) }, (res) => {
194
228
  const chunks = []
195
229
  res.on('data', (c) => chunks.push(c))
196
230
  res.on('error', reject)
@@ -218,10 +252,11 @@ function proxyFetch(proxy, url, init = {}) {
218
252
  })
219
253
  }
220
254
 
221
- /** The fetch to reach `url` with: through the proxy tunnel when one applies, else the built-in one. */
255
+ /** The fetch to reach `url` with: through the proxy tunnel when one applies, else direct (see directFetch). */
222
256
  export function fetchFor(url, env = process.env) {
223
257
  const p = proxyFor(url, env)
224
- return p ? (u, init) => proxyFetch(p, u, init) : globalThis.fetch
258
+ if (p) return (u, init) => proxyFetch(p, u, init)
259
+ return envProxyMode(env) ? directFetch : globalThis.fetch
225
260
  }
226
261
 
227
262
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hivecode-mcp",
3
- "version": "0.14.3",
3
+ "version": "0.14.4",
4
4
  "description": "MCP server that lets any AI coding agent join a Hivecode room — humans and multiple AI agents edit one live codebase with folder-scoped permissions and approval gates.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,6 +42,7 @@
42
42
  "hive-room-mode.js",
43
43
  "hive-room-watcher.js",
44
44
  "hive-mailbox.js",
45
+ "hive-seen.js",
45
46
  "hive-obligations.js",
46
47
  "hive-receipts.js",
47
48
  "hive-throttle.js",
@@ -138,7 +139,7 @@
138
139
  "test:substrate:authority": "node substrate-authority-test.js",
139
140
  "test:substrate:all": "node substrate-test.js && node substrate-live-test.js && node substrate-enforce-test.js && node substrate-authority-test.js",
140
141
  "test:recover": "node tests/recover-test.mjs",
141
- "test:mailbox": "node tests/mailbox-test.mjs && node tests/mcp-wait-test.mjs && node tests/mailbox-noise-test.mjs",
142
+ "test:mailbox": "node tests/mailbox-test.mjs && node tests/mcp-wait-test.mjs && node tests/mailbox-noise-test.mjs && node tests/mail-identity-test.mjs",
142
143
  "test:stophook": "node tests/stop-hook-test.mjs",
143
144
  "test:limits": "node tests/limits-test.mjs && node tests/rate-hold-test.mjs && node tests/persist-batch-test.mjs",
144
145
  "test:merge": "node tests/diff3-test.mjs && node tests/merge-balloon-test.mjs && node tests/icr-merge-test.js && node tests/icr-gap-comments-test.mjs && node tests/icr-comment-fuzz-test.mjs",