hivecode-mcp 0.14.3 → 0.14.5
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/core.js +2 -0
- 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/icr-merge.js +8 -1
- package/icr.js +3 -1
- package/package.json +3 -2
- package/reconcile.cjs +123 -21
- package/sync.js +684 -203
- package/docs/HIVE_RULES.md +0 -41
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
|
-
|
|
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') {
|
package/hive-room-watcher.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
603
|
-
const
|
|
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
|
-
|
|
2400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
156
|
-
`
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
258
|
+
if (p) return (u, init) => proxyFetch(p, u, init)
|
|
259
|
+
return envProxyMode(env) ? directFetch : globalThis.fetch
|
|
225
260
|
}
|
|
226
261
|
|
|
227
262
|
/**
|
package/icr-merge.js
CHANGED
|
@@ -36,7 +36,8 @@ function describeConflicts(conflicts) {
|
|
|
36
36
|
// fixed-point, verified by icr-converge-test.js), so its 'auto' result is used as the
|
|
37
37
|
// merged bytes for supported files; merge3 covers conflicts, fallbacks, and other languages.
|
|
38
38
|
export function icrMerge3(base, mine, theirs, relPath) {
|
|
39
|
-
|
|
39
|
+
// The sync merge: edits on adjacent lines that each replace lines one for one go side by side (#17).
|
|
40
|
+
const lm = merge3(base, mine, theirs, { sideBySide: true })
|
|
40
41
|
if (!supports(relPath)) return lm
|
|
41
42
|
|
|
42
43
|
let r
|
|
@@ -44,6 +45,12 @@ export function icrMerge3(base, mine, theirs, relPath) {
|
|
|
44
45
|
catch { return lm } // ICR must never break a sync
|
|
45
46
|
|
|
46
47
|
if (r.status === 'auto') {
|
|
48
|
+
// THE LINE MERGE KEEPS THE AUTHORS' LAYOUT (#19). A one-line top-level statement is a unit keyed by
|
|
49
|
+
// its own text, so the structural merge sees an edit as a delete plus an insert and can leave the
|
|
50
|
+
// deleted line's blank lines behind. When the line merge is clean and the two differ only in blank
|
|
51
|
+
// lines, the line merge is exactly both sides' edits: take it.
|
|
52
|
+
const blankless = (t) => t.split('\n').filter((l) => l.trim() !== '').join('\n')
|
|
53
|
+
if (!lm.conflict && r.text !== lm.text && blankless(r.text) === blankless(lm.text)) return lm
|
|
47
54
|
const renamed = r.renames && r.renames.length
|
|
48
55
|
return {
|
|
49
56
|
text: r.text, conflict: false, icr: renamed ? 'rename' : 'structural',
|