hivecode-mcp 0.14.2 → 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.
@@ -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'
@@ -68,7 +69,7 @@ export function saveRoomState(file, s) {
68
69
  export function makeRoomWatcher(o) {
69
70
  const {
70
71
  gh, link, branch, label = LABEL, stateFile, deps,
71
- ackMinutes = 10, maxMinutes = 120, maxRuns = 6, maxRunsPerDay = 20, soloGraceMinutes = 2,
72
+ ackMinutes = 10, maxMinutes = 120, maxRuns = 6, maxRunsPerDay = 20, soloGraceMinutes = 2, refusalRetryMinutes = 10,
72
73
  fallbackAgent = '', host = os.hostname(),
73
74
  now = () => Date.now(), log = () => {},
74
75
  } = o
@@ -100,10 +101,23 @@ export function makeRoomWatcher(o) {
100
101
  .split(roomId).join('[room id removed]')
101
102
  .replace(/wss?:\/\/[^\s|)'"`]+(\|[^\s)'"`]*)?/g, '[relay link removed]')
102
103
  .replace(/\broom-[A-Za-z0-9_-]{12,}/g, '[room id removed]')
104
+ // LOCAL PATHS: a refusal's "why" names files on the watcher's machine (a home folder, a temp
105
+ // checkout); the issue is public (0.14.3 P0-2, Claude's review). Not inside URLs.
106
+ .replace(/(?<![\w.:/\\-])(?:[A-Za-z]:[\\/]|\\\\|\/(?:home|Users|tmp|var|root|private|opt|mnt|srv|workspace|data)\/)[^\s'"`)\]]*/g, '[local path removed]')
103
107
  const comment = (n, body) => { try { gh.comment(n, scrubRoom(body)) } catch (e) { log(`#${n}: could not comment on GitHub: ${e.message}`) } }
104
108
  const today = () => new Date(now()).toISOString().slice(0, 10)
105
109
  const runsToday = () => { if (state.day.date !== today()) state.day = { date: today(), runs: 0 }; return state.day.runs }
106
110
 
111
+ // A room line that may not arrive: deps.say is roomSay, confirmed by a second client. When it is
112
+ // not confirmed (or throws) the watcher logs it and carries on; the caller has already put the
113
+ // same news on GitHub, so nobody is left with neither (0.14.3 P0-2).
114
+ async function sayChecked(n, text) {
115
+ let r = null
116
+ try { r = await deps.say(text) } catch (e) { r = { ok: false, why: e && e.message } }
117
+ if (!r || !r.ok) log(`#${n}: could not confirm the room heard it (${(r && (r.why || r.code)) || 'no answer'}); the GitHub comment stands`)
118
+ return r
119
+ }
120
+
107
121
  function needsHuman(n, why, lines = []) {
108
122
  const it = state.issues[n]
109
123
  if (it) it.status = 'needs-human'
@@ -180,7 +194,7 @@ export function makeRoomWatcher(o) {
180
194
  log(`#${n}: withdrawn`)
181
195
  return
182
196
  }
183
- 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 })
184
198
  if (peek && peek.ok && peek.engaged && !a.engagedAt) a.engagedAt = now()
185
199
 
186
200
  // A LONE AGENT CANNOT GET A SECOND AGENT'S REVIEW. When the work is done and the only thing
@@ -197,9 +211,15 @@ export function makeRoomWatcher(o) {
197
211
  const solo = (a.agentsSeen || []).length <= 1
198
212
  const graceMs = (solo ? soloGraceMinutes : ackMinutes) * 60000
199
213
  const allowUnreviewed = !!(a.unreviewedSince && now() - a.unreviewedSince >= graceMs && peek && peek.ok && peek.reachable.length <= 1)
200
- const r = await deps.landIssue({ taskId: a.taskId, issue: { number: n, title: it.title }, lastFailedFingerprint: a.lastFailedFingerprint || '', allowUnreviewed })
214
+ // A REFUSAL IS TRIED AGAIN AFTER A PAUSE even when nothing changed (a push answered 503, a lock
215
+ // on the remote): once per refusalRetryMinutes, never every tick, and a repeat of the same
216
+ // refusal is neither said again nor counted again (antigravity-VM's attack 1). Failing tests
217
+ // are not retried this way: they wait for the room to change.
218
+ let lastFp = a.lastFailedFingerprint || ''
219
+ if (lastFp && a.lastRefusal && now() - (a.refusalTriedAt || a.lastRefusal.at) >= refusalRetryMinutes * 60000) { lastFp = ''; a.refusalTriedAt = now() }
220
+ const r = await deps.landIssue({ taskId: a.taskId, issue: { number: n, title: it.title }, lastFailedFingerprint: lastFp, allowUnreviewed })
201
221
  if (r && !r.ok && r.blocked === 'unreviewed') {
202
- if (!a.unreviewedSince) {
222
+ if (!a.unreviewedSince && !a.unreviewedSaid) {
203
223
  a.unreviewedSince = now()
204
224
  const files = (r.files || []).slice(0, 6).join(', ') || 'the changed files'
205
225
  if (solo) {
@@ -208,8 +228,16 @@ export function makeRoomWatcher(o) {
208
228
  } else {
209
229
  await deps.say(`@all issue #${n} is done and waiting for a review of ${files} by someone who did not write them (hive_review). Nothing lands until then.`)
210
230
  }
231
+ a.unreviewedSaid = true
211
232
  }
212
- } else if (r && !r.ok) a.unreviewedSince = 0
233
+ if (!a.unreviewedSince) a.unreviewedSince = now()
234
+ } else if (r && !r.ok && !r.skipped && !r.code && !r.fingerprint) {
235
+ // Only "not ready" (work reopened, a claim taken) restarts the review wait. A refusal or a
236
+ // skipped landing is not a change in the work: resetting here made a refused solo landing
237
+ // re-announce "nobody else to review" every grace period (antigravity-VM's attack 3).
238
+ a.unreviewedSince = 0
239
+ a.unreviewedSaid = false
240
+ }
213
241
  if (r && r.state && 'lastFailedFingerprint' in r.state) a.lastFailedFingerprint = r.state.lastFailedFingerprint || ''
214
242
  if (r && r.ok && a.kind === 'review') {
215
243
  const pr = r.pr || {}
@@ -234,8 +262,38 @@ export function makeRoomWatcher(o) {
234
262
  }
235
263
  if (r && r.code === 'Q576' && !r.skipped) {
236
264
  a.runs = (a.runs || 0) + 1
265
+ a.lastRefusal = null // failing tests wait for the room to change; they are not retried on a timer
237
266
  if (a.runs >= maxRuns) return needsHuman(n, `the tests failed ${a.runs} times in a row (the limit is ${maxRuns}).`, (peek && peek.lastLines) || [])
238
267
  comment(n, `The tests failed on the agents' work, so nothing was pushed. It went back to the room (attempt ${a.runs} of ${maxRuns}).\n\n${MARK('room', { kind: 'tests-failed', run: a.runs })}`)
268
+ // SAID BY THE WATCHER, CONFIRMED: landIssue's own room line went through a connection that
269
+ // whenDone had already stopped, so the room never heard its tests failed (0.14.3 P0-2).
270
+ await sayChecked(n, `@all the tests failed when landing issue #${n} (attempt ${a.runs} of ${maxRuns}): ${oneLine(r.why, 300)} Nothing was pushed. Fix them in the room; the landing is tried again when the room's files or the branch change.`)
271
+ }
272
+ // A LANDING REFUSED FOR ANY OTHER REASON IS SAID, never swallowed (0.14.3 P0-2). On 0.14.2 a
273
+ // refusal (Q506, a push refused, no git identity...) matched no branch here: nothing in the
274
+ // room, nothing on the issue, and the next tick's identical fingerprint was a silent skip, so
275
+ // issue #9 sat "done" and landed nothing with no word anywhere. Said once per refusal (same
276
+ // code, same fingerprint), counted like failing tests, then a person. The issue comment goes
277
+ // FIRST: an unreachable room must never mean nobody heard (Claude's review).
278
+ if (r && !r.ok && !r.skipped && r.blocked !== 'unreviewed' && r.code !== 'Q576' && (r.code || r.fingerprint)) {
279
+ const fp = r.fingerprint || (r.state && r.state.lastFailedFingerprint) || ''
280
+ const key = `${r.code || ''}|${fp || oneLine(r.why, 120)}`
281
+ if (a.refusedKey !== key) {
282
+ a.refusedKey = key
283
+ a.refusals = (a.refusals || 0) + 1
284
+ const code = r.code || 'refused'
285
+ const why = oneLine(r.why, 300) || 'no reason given'
286
+ a.lastRefusal = { code, why, at: now() }
287
+ log(`#${n}: landing refused (${code}): ${why}`)
288
+ if (a.refusals >= maxRuns) return needsHuman(n, `the landing was refused ${a.refusals} times (the limit is ${maxRuns}); the last time ${code}: ${why}`, (peek && peek.lastLines) || [])
289
+ comment(a.kind === 'review' ? a.pr : n, `The agents' work is done, but Hivecode refused to land it (${code}): ${oneLine(r.why, 400) || 'no reason given'} Nothing was pushed. It went back to the room (refusal ${a.refusals} of ${maxRuns}); it is tried again when the room's files or the branch change.\n\n${MARK('room', { kind: 'refused', code })}`)
290
+ await sayChecked(n, `@all issue #${n} is done, but its landing was REFUSED (${code}): ${why} Nothing was pushed. Fix the cause in the room; the landing is tried again when the room's files or the branch change.`)
291
+ } else if (a.lastRefusal) {
292
+ // The same refusal again (a retry after the pause): not said or counted again, but
293
+ // `github status` shows it is still happening, so a silent wait is never mistaken for
294
+ // progress (Claude's review).
295
+ a.lastRefusal.seen = now()
296
+ }
239
297
  }
240
298
  if (now() - a.postedAt > maxMinutes * 60000) return needsHuman(n, `the room worked on it for more than ${maxMinutes} minutes without a landing.`, (peek && peek.lastLines) || [])
241
299
 
@@ -338,7 +396,7 @@ export function makeRoomWatcher(o) {
338
396
  return { postFailed: n }
339
397
  }
340
398
  it.status = 'active'
341
- 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: '' }
342
400
  // Commands already on the issue (a "/hivecode stop" from before this run) are history: only
343
401
  // what is said from now on stops it.
344
402
  try { await commands(n, 0) } catch { /* read again next tick */ }
@@ -395,6 +453,10 @@ export function describeRoomState(s, { now = Date.now(), maxRunsPerDay = (s && s
395
453
  out.push(` working on ${what} for ${mins(a.postedAt)} min; ${a.engagedAt ? 'an agent has taken it' : 'NO agent has taken it yet'}${a.runs ? `; tests failed ${a.runs} time(s)` : ''}`)
396
454
  if (a.fallback) out.push(` fallback agent started ${mins(a.fallback.at)} min ago (${a.fallback.why})`)
397
455
  if (a.unreviewedSince) out.push(` done, waiting ${mins(a.unreviewedSince)} min for a review by an agent that did not write it`)
456
+ if (a.lastRefusal) {
457
+ const still = a.lastRefusal.seen ? `; still refused (same reason) for ${mins(a.lastRefusal.at)} min, last tried ${mins(a.lastRefusal.seen)} min ago` : ''
458
+ out.push(` landing refused ${mins(a.lastRefusal.at)} min ago (${a.lastRefusal.code}): ${oneLine(a.lastRefusal.why, 200)}${still}`)
459
+ }
398
460
  } else if (s.openPr) {
399
461
  out.push(` waiting for PR #${s.openPr} to be merged or closed before the next issue (one open PR per room)`)
400
462
  } else {
@@ -522,7 +584,30 @@ export async function runRoomMode({ f, opt, gh, id, info, root, gitDir, label, l
522
584
  }
523
585
 
524
586
  /** A passive look at the room: who can hear, and whether anyone has engaged with this issue. */
525
- 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 }) {
526
611
  const { relay, room } = parseLink(link)
527
612
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-room-peek-'))
528
613
  const peek = startSync({ relay, room, dir, name: 'GitHub-peek', kind: 'ai', passive: true, syncFiles: false, token, log: () => {} })
@@ -537,15 +622,9 @@ export async function peekRoom({ link, issueNumber, taskId, since = 0, token = '
537
622
  const members = peek.members().filter((m) => m && m.name && !/^GitHub/.test(m.name) && m.kind !== 'human' && !m.passive)
538
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)
539
624
  const chat = peek.doc.getArray('chat').toArray()
540
- // Chat times are clock strings ("21:04:39") with no date, so "since the issue was posted" is
541
- // told by POSITION: lines after the room's own post of this issue.
542
- const ref = new RegExp(`#${Number(issueNumber)}(?!\\d)`)
543
- const isAgent = (m) => m && m.kind !== 'human' && !/^GitHub/.test(String(m.by || ''))
544
- let from = -1
545
- // The LAST post of this issue: a retry is posted as "Issue #12 (attempt 2): ...".
546
- const posted = new RegExp(`Issue #${Number(issueNumber)}(?: \\(attempt \\d+\\))?:`)
547
- 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 }
548
- 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 })
549
628
  const t = peek.doc.getMap('tasks').get(taskId)
550
629
  const claimed = [...peek.doc.getMap('claims').values()].some((c) => c && c.by && Number(c.since || c.at || 0) >= since)
551
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:
@@ -180,6 +200,28 @@ export function makeQueue({
180
200
 
181
201
  export const SLACK_TIMEOUT_MS = 15000
182
202
 
203
+ // HOW SLACK READS A CALL'S ARGUMENTS (0.14.3 P0-4). Read methods take their arguments only from the
204
+ // query string or a form body: a JSON body is ignored, so on 0.14.2 conversations.info answered
205
+ // invalid_arguments and users.info user_not_found in a real workspace (measured 9 Nov 2026), which
206
+ // broke `slack check`, the --slack-channel preflight, inbound stop/retry and stop verification.
207
+ // Every fake Slack in the tests accepted JSON, so all of it was green. Write methods (chat.*,
208
+ // agents.*, reactions.*...) take JSON and stay JSON: their blocks and metadata are nested objects.
209
+ const FORM_METHOD = /^(?:(?:conversations|users|team|bots)\.[A-Za-z.]+|auth\.test)$/
210
+ export function slackEncoding(method) {
211
+ return FORM_METHOD.test(String(method || '')) ? 'form' : 'json'
212
+ }
213
+
214
+ /** A form body Slack reads: undefined and null are LEFT OUT (never the text "undefined", which Slack
215
+ * would read as a real channel or user id); a nested value is JSON inside its field, as Slack takes it. */
216
+ export function slackForm(body = {}) {
217
+ const p = new URLSearchParams()
218
+ for (const [k, v] of Object.entries(body || {})) {
219
+ if (v === undefined || v === null) continue
220
+ p.append(k, typeof v === 'object' ? JSON.stringify(v) : String(v))
221
+ }
222
+ return p.toString()
223
+ }
224
+
183
225
  /** POST to Slack's Web API. Kept tiny and injectable so the bridge can be tested without a
184
226
  * workspace -- the thing worth testing is our routing, not Slack's HTTP.
185
227
  * Applies a 15s AbortSignal timeout by default so a hung TCP socket never stalls the queue. */
@@ -191,10 +233,14 @@ export async function slackPost(
191
233
  apiBase = process.env.HIVE_SLACK_API || DEFAULT_API,
192
234
  timeoutMs = Number(process.env.HIVE_SLACK_TIMEOUT_MS) || SLACK_TIMEOUT_MS,
193
235
  ) {
236
+ const form = slackEncoding(method) === 'form'
194
237
  const r = await fetchImpl(`${apiBase}/${method}`, {
195
238
  method: 'POST',
196
- headers: { 'Content-Type': 'application/json; charset=utf-8', Authorization: `Bearer ${token}` },
197
- body: JSON.stringify(body),
239
+ headers: {
240
+ 'Content-Type': form ? 'application/x-www-form-urlencoded; charset=utf-8' : 'application/json; charset=utf-8',
241
+ Authorization: `Bearer ${token}`,
242
+ },
243
+ body: form ? slackForm(body) : JSON.stringify(body),
198
244
  signal: AbortSignal.timeout(timeoutMs),
199
245
  })
200
246
  const retryHeader = r.headers && typeof r.headers.get === 'function' ? Number(r.headers.get('retry-after') || 0) : 0
@@ -2120,6 +2166,13 @@ export async function runSlackCheck({
2120
2166
  clearTimeout(waitTimer)
2121
2167
 
2122
2168
  if (!arrived) {
2169
+ // Over HTTPS polling nothing is subscribed: the bridge reads the thread itself, and its read
2170
+ // scopes were checked above. So no reply means nobody replied in time, and sending the person
2171
+ // off to reinstall the app was wrong (9 Nov, the first real check: 0 replies in the thread).
2172
+ if (pollInbound) {
2173
+ logErr(`Q634 no reply arrived in the thread within ${Math.round(timeoutMs / 1000)} s. Replies are read over HTTPS, which needs no event subscription, and the read scopes are in place: reply anything in the thread of "Hivecode check: reply anything in this thread" in #${channelName} while the check waits (raise --timeout for more time).`)
2174
+ return 1
2175
+ }
2123
2176
  const manifestName = agent ? 'slack-app-manifest-agent.json' : 'slack-app-manifest-inbound.json'
2124
2177
  logErr(`Q634 replies did not arrive. Event subscriptions are probably missing: reinstall from ${manifestName}`)
2125
2178
  return 1
@@ -2363,8 +2416,13 @@ export async function main(argv = process.argv.slice(2), { log = console.log, lo
2363
2416
  }
2364
2417
  const chat = hive.doc.getArray('chat')
2365
2418
  const savedCursor = Number(bridge.threads.get(CURSOR_KEY)) || 0
2366
- let at = savedCursor > 0 && savedCursor <= chat.length ? savedCursor : chat.length
2367
- 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 }))
2368
2426
  await bridge.watchRoom()
2369
2427
  log(`READY: [hive-slack] @${pre.botUser} bridging ${room} -> #${pre.channelName} (${CHANNEL}) — resume: ${allowParsed.ids.length ? allowParsed.ids.join(', ') : 'off'}`)
2370
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