hivecode-mcp 0.12.4 → 0.13.0
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 +11 -40
- package/README.md +421 -346
- package/docs/ALWAYS_ON.md +310 -0
- package/docs/GITHUB_WORKFLOW.md +334 -0
- package/docs/MCP.md +157 -118
- package/docs/QUICKSTART.md +266 -0
- package/docs/SELF_HOSTING.md +2 -2
- package/docs/SLACK_SETUP.md +450 -0
- package/docs/TROUBLESHOOTING.md +87 -0
- package/hive-await.js +25 -5
- package/hive-branch.js +55 -25
- package/hive-github.js +1904 -0
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +5 -2
- package/hive-mcp-http.js +580 -0
- package/hive-mcp.js +109 -24
- package/hive-notify.js +233 -0
- package/hive-pr.js +17 -4
- package/hive-relay-health.js +230 -230
- package/hive-room-key.js +209 -0
- package/hive-session.js +114 -0
- package/hive-setup.js +14 -4
- package/hive-slack-agent.js +396 -0
- package/hive-slack.js +1809 -0
- package/hive-stop-hook.js +7 -4
- package/hive-time.js +71 -0
- package/hive-webhook.js +54 -0
- package/icr.js +851 -851
- package/limits.js +3 -3
- package/package.json +189 -172
- package/slack-app-manifest-agent.json +61 -0
- package/slack-app-manifest-inbound.json +36 -0
- package/slack-app-manifest.json +27 -0
- package/sync.js +4790 -4691
package/hive-mcp.js
CHANGED
|
@@ -37,6 +37,8 @@ if (!process.env.HIVE_PROVENANCE) process.env.HIVE_PROVENANCE = 'on'
|
|
|
37
37
|
if (!process.env.HIVE_FORK_GATE) process.env.HIVE_FORK_GATE = 'on'
|
|
38
38
|
import { startSync, parseLink } from './sync.js'
|
|
39
39
|
import { checkRelay, currentRelayFor, controlUrl, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
|
|
40
|
+
import { newPersonalRoom, saveRoomKey, loadRoomKey, hostToken, main as roomKeyMain } from './hive-room-key.js'
|
|
41
|
+
import { isSecuredRoom } from './token.js'
|
|
40
42
|
import { createMailbox, formatMessage, mentionsName, mentionedNames, mentionsAll, unansweredFor, priorityOf, ambientDigest, validateMemberName, collapseRepeats, findClosestKnown, isDirectAddress } from './hive-mailbox.js'
|
|
41
43
|
import { INSTRUCTIONS, JOIN_RULES } from './hive-instructions.js'
|
|
42
44
|
import { makeBusy, isBusy, describeRecipient } from './hive-busy.js'
|
|
@@ -48,6 +50,7 @@ import { findSecrets, describeSecret } from './hive-secrets.js' // a key never g
|
|
|
48
50
|
import { makeThrottle, RULES_NUDGE } from './hive-throttle.js'
|
|
49
51
|
import * as HB from './hive-branch.js' // a room bound to a git branch: hive_join { branch }, and the branch/pr CLI
|
|
50
52
|
import { isArmed, armedFile, listenCommand, listenHow, listenOrder, LISTEN_IDLE_MIN, AWAIT_JS } from './hive-listen.js'
|
|
53
|
+
import { SESSION_FILE_NAME, readAgents, writeAgents, ownRecord, excludeFromGit } from './hive-session.js'
|
|
51
54
|
|
|
52
55
|
const DEFAULT_RELAY = process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY // written once, in hive-privacy.js
|
|
53
56
|
let session = null // { hive, room, relay, dir, name, mailbox }
|
|
@@ -86,24 +89,44 @@ function resolveRoomRaw(link, dir) {
|
|
|
86
89
|
return { relay: DEFAULT_RELAY, room: 'room-' + crypto.randomBytes(13).toString('base64url'), token: '', mode: 'HOSTED (new room)' }
|
|
87
90
|
}
|
|
88
91
|
|
|
89
|
-
// Session persistence:
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
92
|
+
// Session persistence: in the folder this server was started in, so a restart of the MCP host
|
|
93
|
+
// rejoins as the same agent instead of silently dropping it from the room. That folder is often
|
|
94
|
+
// shared: two Claude Code sessions in one repo is the obvious way to run a branch room. So the file
|
|
95
|
+
// keeps one record per agent, each with this server's pid, and every read takes only this
|
|
96
|
+
// process's own record (hive-session.js says why it once held one agent, and what that broke).
|
|
97
|
+
const SESSION_FILE = path.join(process.cwd(), SESSION_FILE_NAME)
|
|
98
|
+
const CONV = process.env.CLAUDE_CODE_SESSION_ID || process.env.ANTIGRAVITY_CONVERSATION_ID || ''
|
|
99
|
+
let keptOutOfGit = false
|
|
93
100
|
function persistSession({ dir, name, owner, cursor, authed = false, kind = 'ai', passive = false }) {
|
|
94
101
|
try {
|
|
95
|
-
const
|
|
96
|
-
const
|
|
102
|
+
const agents = readAgents(SESSION_FILE)
|
|
103
|
+
const prev = ownRecord(agents, { conv: CONV })
|
|
104
|
+
const chatCursor = cursor === undefined ? (prev && prev.name === name ? prev.chatCursor : undefined) : cursor
|
|
97
105
|
// `authed`: the Stop hook reads this file, and must not order a listener into a room that checks
|
|
98
106
|
// tokens -- the agent's shell does not hold this server's token, so that listener could not join.
|
|
99
107
|
// `kind` and `passive`: a resume rejoins from this file, and without them a human came back as an
|
|
100
108
|
// AI and a passive participant as one the room waits on; the Stop hook reads `passive` too.
|
|
101
|
-
|
|
102
|
-
...(kind && kind !== 'ai' ? { kind } : {}), ...(passive ? { passive: true } : {})
|
|
109
|
+
const rec = { dir, name, owner, chatCursor, ...(authed ? { authed: true } : {}),
|
|
110
|
+
...(kind && kind !== 'ai' ? { kind } : {}), ...(passive ? { passive: true } : {}),
|
|
111
|
+
pid: process.pid, ...(CONV ? { conv: CONV } : {}), at: Date.now() }
|
|
112
|
+
for (const [n, s] of Object.entries(agents)) if (n !== name && s.pid === process.pid) delete agents[n] // this server's earlier identity
|
|
113
|
+
agents[name] = rec
|
|
114
|
+
writeAgents(SESSION_FILE, agents, rec)
|
|
115
|
+
// The launch folder is often the developer's own checkout: keep our files out of their `git status`.
|
|
116
|
+
if (!keptOutOfGit) { keptOutOfGit = true; excludeFromGit(path.dirname(SESSION_FILE)) }
|
|
103
117
|
} catch { /* best-effort */ }
|
|
104
118
|
}
|
|
105
119
|
function readSessionFile() {
|
|
106
|
-
try { return
|
|
120
|
+
try { return ownRecord(readAgents(SESSION_FILE), { conv: CONV }) } catch { return null }
|
|
121
|
+
}
|
|
122
|
+
/** Take this agent off the record (it left, or was refused), and only this agent. */
|
|
123
|
+
function forgetSession() {
|
|
124
|
+
try {
|
|
125
|
+
const agents = readAgents(SESSION_FILE)
|
|
126
|
+
const mine = ownRecord(agents, { conv: CONV })
|
|
127
|
+
for (const [n, s] of Object.entries(agents)) if (s.pid === process.pid || s === mine) delete agents[n]
|
|
128
|
+
writeAgents(SESSION_FILE, agents)
|
|
129
|
+
} catch { /* best-effort */ }
|
|
107
130
|
}
|
|
108
131
|
/** Save the read cursor so a host restart does not reopen the blind window it just closed. */
|
|
109
132
|
function persistCursor() {
|
|
@@ -119,7 +142,7 @@ function persistCursor() {
|
|
|
119
142
|
async function tryResumeSession() {
|
|
120
143
|
try {
|
|
121
144
|
if (!fs.existsSync(SESSION_FILE)) return
|
|
122
|
-
const s = readSessionFile()
|
|
145
|
+
const s = readSessionFile() // only this process's own record: never an agent still running beside it
|
|
123
146
|
if (s && s.dir && s.name && fs.existsSync(path.join(path.resolve(s.dir), '.hive.json'))) await joinRoom(s)
|
|
124
147
|
} catch { /* resume is best-effort; the agent can always hive_join explicitly */ }
|
|
125
148
|
}
|
|
@@ -166,7 +189,7 @@ function defaultJoinTarget(link, name) {
|
|
|
166
189
|
return { dir: path.join(HIVE_HOME, safeSegment(room), safeSegment(name)), link, hostedHere: false }
|
|
167
190
|
}
|
|
168
191
|
|
|
169
|
-
async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false, branch = '', repo = '' }) {
|
|
192
|
+
async function joinRoom({ link = '', dir = '', name = `agent-${crypto.randomBytes(2).toString('hex')}`, owner = '', token = '', chatCursor, kind = 'ai', passive = false, branch = '', repo = '', private: personal = false }) {
|
|
170
193
|
// REFUSED BEFORE THE OLD SESSION IS TORN DOWN. joinRoom's first act is to stop whatever session
|
|
171
194
|
// exists, so validating after that point would leave an agent with a bad name holding nothing at
|
|
172
195
|
// all -- it would lose a working room to a refusal it could have been given for free.
|
|
@@ -178,9 +201,26 @@ Nothing was joined and your current session is untouched. Re-run hive_join with
|
|
|
178
201
|
|
|
179
202
|
This is refused rather than fixed for you because renaming silently is worse: if another member is already called "${nameCheck.suggested}", you would be merged into them -- one awareness entry, one read cursor, one mailbox -- and neither of you would be told. Your own prompt would also still say your name is "${name}", so you would stop recognising messages about yourself.`
|
|
180
203
|
}
|
|
204
|
+
// A PERSONAL ROOM IS ALWAYS A NEW ONE (8 Oct 2026, hive-room-key.js). Refused here, before the old
|
|
205
|
+
// session is torn down, for the same reason as the name check above.
|
|
206
|
+
if (personal && (link || branch)) {
|
|
207
|
+
return `private: true hosts a NEW personal room, so it cannot be combined with a ${link ? 'link' : 'branch'}.\n\nNothing was joined and your current session is untouched.`
|
|
208
|
+
}
|
|
209
|
+
if (personal && dir && roomIn(dir)) {
|
|
210
|
+
return `${path.resolve(dir)} already belongs to room ${roomIn(dir)}, and a folder holds one room. Pass a new dir, or leave out private to rejoin that room.\n\nNothing was joined and your current session is untouched.`
|
|
211
|
+
}
|
|
181
212
|
// A BRANCH ROOM (hive-branch.js): the link comes from the repo's git config, and the folder is
|
|
182
213
|
// this participant's own, marked so git refuses to run in it. Resolved and refused BEFORE the old
|
|
183
214
|
// session is torn down, for the same reason as the name check above.
|
|
215
|
+
// SINGLE IDENTITY PER MCP PROCESS (Q637). Some host apps (Antigravity, Cursor, Claude Desktop)
|
|
216
|
+
// run ONE shared MCP server process across all their agent windows and tabs. A second join with a
|
|
217
|
+
// different name while a session is live would silently usurp the first session, breaking chat
|
|
218
|
+
// identity and authorship attribution. Refused FIRST, before anything below touches the disk: the
|
|
219
|
+
// branch path marks the folder as not-a-checkout, and a refusal that has already written there is
|
|
220
|
+
// not "nothing was joined" (antigravity-3, 1 Nov).
|
|
221
|
+
if (session && session.name !== name) {
|
|
222
|
+
return `Q637 This Hivecode server is already in use by '${session.name}' in ${session.room} (${session.dir}). If you are a different agent, you are sharing one MCP server with it. Do not leave or rejoin; tell your person: add a second server entry (for example 'hivecode-2') in your app's MCP config and restart the app (docs/JOIN_WITH_AGENT.md). If you ARE '${session.name}' and mean to switch, join again with the name '${session.name}'.\n\nNothing was joined and your current session is untouched.`
|
|
223
|
+
}
|
|
184
224
|
let branchCtx = null
|
|
185
225
|
if (branch) {
|
|
186
226
|
const untouched = '\n\nNothing was joined and your current session is untouched.'
|
|
@@ -195,11 +235,20 @@ This is refused rather than fixed for you because renaming silently is worse: if
|
|
|
195
235
|
}
|
|
196
236
|
if (session) { try { session.hive.stop() } catch {} ; session = null }
|
|
197
237
|
let hostedHere = false
|
|
238
|
+
// The new personal room exists only in memory until the privacy gate below has passed: a refused
|
|
239
|
+
// join must leave nothing behind, and that includes a key for a room nobody ever entered.
|
|
240
|
+
let fresh = null
|
|
241
|
+
if (personal) {
|
|
242
|
+
fresh = newPersonalRoom()
|
|
243
|
+
link = `${DEFAULT_RELAY}|${fresh.room}`
|
|
244
|
+
if (!dir) dir = path.join(HIVE_HOME, fresh.room, safeSegment(name))
|
|
245
|
+
hostedHere = true
|
|
246
|
+
}
|
|
198
247
|
if (!dir) ({ dir, link, hostedHere } = defaultJoinTarget(link, name))
|
|
199
248
|
const ROOT = path.resolve(dir)
|
|
200
249
|
fs.mkdirSync(ROOT, { recursive: true })
|
|
201
250
|
const { relay, room, token: linkToken, mode: resolvedMode, migratedFrom } = resolveRoom(link, dir)
|
|
202
|
-
const mode = hostedHere ? 'HOSTED (new room)' : resolvedMode
|
|
251
|
+
const mode = hostedHere ? (fresh ? 'HOSTED (new personal room)' : 'HOSTED (new room)') : resolvedMode
|
|
203
252
|
// THE PRIVACY GATE, and note where it sits: after the relay is resolved, BEFORE the
|
|
204
253
|
// .hive.json write below and before startSync opens a socket. A refused join must leave
|
|
205
254
|
// nothing behind -- no config pointing at the wrong relay for the next process to find,
|
|
@@ -218,8 +267,13 @@ folder: ${ROOT}
|
|
|
218
267
|
NOTHING WAS JOINED. No socket was opened, no file was published, and ${path.join(ROOT, '.hive.json')}
|
|
219
268
|
was not written. Fix the relay and call hive_join again.`
|
|
220
269
|
}
|
|
270
|
+
if (fresh) saveRoomKey(fresh, { relay, host: name })
|
|
221
271
|
// token precedence: explicit arg > token baked into the join link > $HIVE_TOKEN.
|
|
222
|
-
|
|
272
|
+
let useToken = token || linkToken || process.env.HIVE_TOKEN || ''
|
|
273
|
+
// A personal room this machine hosts: the host's own token is minted from the key, and renewed
|
|
274
|
+
// before it runs out, so the host can never be locked out of its own room. Anyone else joining
|
|
275
|
+
// here keeps the token it came with.
|
|
276
|
+
if (!token && isSecuredRoom(room)) useToken = hostToken(room, name, useToken, { kind: kind || 'ai', owner })
|
|
223
277
|
// persist the token too, so a re-join from .hive.json keeps the grant (the
|
|
224
278
|
// invitee pastes the link once; subsequent joins just read the folder config).
|
|
225
279
|
// pinnedRelay SURVIVES the rewrite. This line used to write only relay/room/token, so a pin
|
|
@@ -333,7 +387,7 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
333
387
|
const who = [d.kind, d.owner ? `owner ${d.owner}` : '', d.app].filter(Boolean).join(', ')
|
|
334
388
|
try { hive.stop() } catch { }
|
|
335
389
|
session = null
|
|
336
|
-
|
|
390
|
+
forgetSession()
|
|
337
391
|
return [
|
|
338
392
|
`NOT JOINED -- Q581: ${d.name} is already in this room (${who}), and two participants cannot share a name.`,
|
|
339
393
|
'Everything here is keyed by name: you would each be told you hold the other\'s claims, read the',
|
|
@@ -444,10 +498,17 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
444
498
|
// What a person without an agent clicks. Pasting wss:// into a browser's address bar searches for it.
|
|
445
499
|
const browserUrl = controlUrl(inviteLink)
|
|
446
500
|
const ownControl = useToken ? controlUrl(`${relay}|${room}|${useToken}`) : ''
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
501
|
+
// A PERSONAL ROOM (hs_ id) is let into by an invite from its host, and nothing else: the plain
|
|
502
|
+
// link below gets a stranger nothing, so it is not offered as the way in.
|
|
503
|
+
const personalRoom = isSecuredRoom(room)
|
|
504
|
+
const ownControlLine = ownControl ? `\nyour Control Room (carries YOUR token: open it yourself, never share it): ${ownControl}` : ''
|
|
505
|
+
const tokenNote = personalRoom
|
|
506
|
+
? (loadRoomKey(room)
|
|
507
|
+
? `\nPERSONAL ROOM: the relay lets in only people you invite, even on the public relay. It still carries the files, so for code that must never leave your machines, run your own relay. Invite one person at a time (each link works only for them): npx -y hivecode-mcp@latest invite <name> --dir "${ROOT}" (add --reader, or --paths src,docs). Take one back: npx -y hivecode-mcp@latest revoke <name> --dir "${ROOT}"`
|
|
508
|
+
: `\nPERSONAL ROOM: only people its host invites can join. To bring someone in, ask the host for an invite of their own; never pass on yours.`) + ownControlLine
|
|
509
|
+
: useToken
|
|
510
|
+
? `\nthis room checks tokens: each person joins with a token of their own. Mint one per person with npx -y -p hivecode-relay hivecode-token --name <them> --room "${room}" --role agent (add --paths to limit them) and send it to them directly. Never pass on yours: whoever holds it joins as you.` + ownControlLine
|
|
511
|
+
: ''
|
|
451
512
|
// THE VERDICT, NOT JUST THE URL. `relay: wss://...` on its own is unreadable to anybody
|
|
452
513
|
// who has not run a relay -- the public one and a company's own look identical. The
|
|
453
514
|
// bracketed verdict is what makes "did my source just leave the building?" answerable
|
|
@@ -478,12 +539,13 @@ was not written. Fix the relay and call hive_join again.`
|
|
|
478
539
|
// One line per thing a person acts on (the stranger walk, 3 Oct: as one line it pasted into chat
|
|
479
540
|
// apps as a wall of links).
|
|
480
541
|
const people = browserUrl ? `\n No AI agent? Open ${browserUrl}` : ''
|
|
481
|
-
const invite =
|
|
482
|
-
|
|
483
|
-
|
|
542
|
+
const invite = personalRoom ? ''
|
|
543
|
+
: /^HOSTED/.test(mode)
|
|
544
|
+
? `\nto invite someone, forward this as is:\n Join my Hivecode room. In a terminal, run this once (needs Node.js 18+), then do what it prints:\n npx -y hivecode-mcp@latest setup "${inviteLink}"${people}`
|
|
545
|
+
: (browserUrl ? `\nbrowser (people without an agent): ${browserUrl}` : '')
|
|
484
546
|
// No "announce your version" rule any more: since 29 Sep the room flags a mixed build itself
|
|
485
547
|
// (sync.js checkBuilds), and the joiner is told here, by name, when one is already in the room.
|
|
486
|
-
return `${branchNote}${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION_LABEL}\nothers here: ${others.join(', ') || 'none yet'}${mixedNote}\
|
|
548
|
+
return `${branchNote}${mode}\nroom: ${room}\nrelay: ${relay} [${priv.headline}]\nfolder: ${ROOT}\nhivecode: ${VERSION_LABEL}\nothers here: ${others.join(', ') || 'none yet'}${mixedNote}\n${personalRoom ? 'room link (lets nobody in without an invite)' : 'invite link'}: ${inviteLink}${tokenNote}${invite}${movedNote}${privNote}${updateNotice() ? '\n\n' + updateNotice() : ''}\n\n--- FOLLOW THESE RULES ---\n${rulesText}`
|
|
487
549
|
}
|
|
488
550
|
|
|
489
551
|
// A MIXED BUILD, TOLD TO THE ONE WHO CAN ACT ON IT (2 Oct 2026). The room's own warning names
|
|
@@ -705,7 +767,7 @@ function withheldNotice(s) {
|
|
|
705
767
|
|
|
706
768
|
const TOOLS = [
|
|
707
769
|
{ name: 'hive_join', description: 'Join a Hivecode room, or host a new one, for a folder. Uses the join link if given, else <dir>/.hive.json, else hosts. Returns the room details and the rules you must follow.',
|
|
708
|
-
inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'join link "wss://relay|room"' }, dir: { type: 'string', description: 'folder to sync; default ~/hivecode/<room>/<name>. Never share a folder with another participant.' }, name: { type: 'string', description: 'your display name in the room' }, owner: { type: 'string', description: 'the human who approves tasks aimed at you' }, token: { type: 'string', description: 'token for a secured room (default $HIVE_TOKEN)' }, kind: { type: 'string', description: '"ai" (default) or "human"' }, passive: { type: 'boolean', description: 'true = a listener or bot that never takes turns or answers' }, branch: { type: 'string', description: 'instead of link: the room bound to this git branch (npx -y hivecode-mcp@latest branch <name>)' }, repo: { type: 'string', description: 'the repo for branch (default: your working directory)' } } } },
|
|
770
|
+
inputSchema: { type: 'object', properties: { link: { type: 'string', description: 'join link "wss://relay|room"' }, dir: { type: 'string', description: 'folder to sync; default ~/hivecode/<room>/<name>. Never share a folder with another participant.' }, name: { type: 'string', description: 'your display name in the room' }, owner: { type: 'string', description: 'the human who approves tasks aimed at you' }, token: { type: 'string', description: 'token for a secured room (default $HIVE_TOKEN)' }, kind: { type: 'string', description: '"ai" (default) or "human"' }, passive: { type: 'boolean', description: 'true = a listener or bot that never takes turns or answers' }, branch: { type: 'string', description: 'instead of link: the room bound to this git branch (npx -y hivecode-mcp@latest branch <name>)' }, repo: { type: 'string', description: 'the repo for branch (default: your working directory)' }, private: { type: 'boolean', description: 'host a NEW room only people you invite can join' } } } },
|
|
709
771
|
{ name: 'hive_assign', description: 'Give a task to a participant by name. A task for an AI with an owner stays PENDING until that owner approves.',
|
|
710
772
|
inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'participant name' }, text: { type: 'string', description: 'the task' } }, required: ['to', 'text'] } },
|
|
711
773
|
{ name: 'hive_read_tasks', description: 'Tasks aimed at you (act only on accepted ones) and all pending tasks awaiting approval.',
|
|
@@ -1674,6 +1736,7 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
|
|
|
1674
1736
|
room: s.room, relay: s.relay, dir: s.dir, name: s.name, hivecode: VERSION, ...(BUILD ? { build: BUILD } : {}), ...(updateNotice() ? { update: updateNotice() } : {}),
|
|
1675
1737
|
...(clientName() ? { app: clientName() } : {}), // which agent app this is: support needs it, and so does waitPolicy
|
|
1676
1738
|
privacy: pv.headline,
|
|
1739
|
+
access: isSecuredRoom(s.room) ? (loadRoomKey(s.room) ? 'personal: invite only, and you host it (npx -y hivecode-mcp@latest invite <name>)' : 'personal: invite only') : 'open: anyone with the link can join',
|
|
1677
1740
|
...(pv.warn ? { privacyWarning: pv.warn } : {}),
|
|
1678
1741
|
...(pv.pin && pv.pin.pinned ? { relayPin: pv.pin.origins.join(', ') } : {}),
|
|
1679
1742
|
unread: mailbox().peek(chatArray()).unreadCount,
|
|
@@ -1689,7 +1752,7 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
|
|
|
1689
1752
|
// HIV-P11. A deliberate leave is the one departure the room KNOWS about, so the activity
|
|
1690
1753
|
// row is dropped rather than left to read as GONE. GONE means "stopped without saying so",
|
|
1691
1754
|
// and diluting it with agents that signed off correctly is how a signal stops being read.
|
|
1692
|
-
case 'hive_leave': { try { ACT.forget(activityMap(), requireSession().name) } catch { }; requireSession().hive.stop(); session = null;
|
|
1755
|
+
case 'hive_leave': { try { ACT.forget(activityMap(), requireSession().name) } catch { }; requireSession().hive.stop(); session = null; forgetSession(); return text('left the room') }
|
|
1693
1756
|
default: return err(`unknown tool: ${name}`)
|
|
1694
1757
|
}
|
|
1695
1758
|
} catch (e) { return err(`error: ${e.message}`) }
|
|
@@ -1863,6 +1926,28 @@ if (isEntryPoint && process.argv[2] === 'setup') {
|
|
|
1863
1926
|
// `npx -y hivecode-mcp@latest branch <name>` binds a room to a git branch; `pr <name>` lands its work on it.
|
|
1864
1927
|
// A CLI run like setup: nobody is speaking JSON-RPC to it, so it never opens stdio.
|
|
1865
1928
|
process.exit(await HB.main(process.argv.slice(2)))
|
|
1929
|
+
} else if (isEntryPoint && (process.argv[2] === 'invite' || process.argv[2] === 'revoke')) {
|
|
1930
|
+
// `invite <name>` makes one person's link into a personal room this machine hosts; `revoke <name>`
|
|
1931
|
+
// takes it back. Commands rather than tools: see hive-room-key.js for why.
|
|
1932
|
+
process.exit(await roomKeyMain(process.argv.slice(2)))
|
|
1933
|
+
} else if (isEntryPoint && (process.argv[2] === 'github' || process.argv[2] === 'notify' || process.argv[2] === 'slack')) {
|
|
1934
|
+
// `github start|status`: issues labelled hivecode come back as pull requests (hive-github.js).
|
|
1935
|
+
// `notify "<link>"`: a person hears on Slack or Discord when an agent is waiting on them
|
|
1936
|
+
// (hive-notify.js). `slack start "<link>" --channel <id>` or `slack check --channel <id>`:
|
|
1937
|
+
// threads per task in Slack and end-to-end setup check (hive-slack.js).
|
|
1938
|
+
// `slack agent "<link>" --channel <id>`: Slack assistant panel and 1:1 DMs (hive-slack-agent.js).
|
|
1939
|
+
// `slack mcp-server "<link>" --port <port>`: Streamable HTTP MCP server for Slackbot (hive-mcp-http.js).
|
|
1940
|
+
if (process.argv[2] === 'slack' && process.argv[3] === 'agent') {
|
|
1941
|
+
const mod = await import('./hive-slack-agent.js')
|
|
1942
|
+
process.exit(await mod.main(process.argv.slice(4)))
|
|
1943
|
+
} else if (process.argv[2] === 'slack' && process.argv[3] === 'mcp-server') {
|
|
1944
|
+
const mod = await import('./hive-mcp-http.js')
|
|
1945
|
+
process.exit(await mod.main(process.argv.slice(4)))
|
|
1946
|
+
} else {
|
|
1947
|
+
const modPath = process.argv[2] === 'github' ? './hive-github.js' : process.argv[2] === 'slack' ? './hive-slack.js' : './hive-notify.js'
|
|
1948
|
+
const mod = await import(modPath)
|
|
1949
|
+
process.exit(await mod.main(process.argv.slice(3)))
|
|
1950
|
+
}
|
|
1866
1951
|
} else if (isEntryPoint) {
|
|
1867
1952
|
const transport = new StdioServerTransport()
|
|
1868
1953
|
// STDIN CLOSING IS THE AGENT APP SAYING GOODBYE -- the MCP spec's stdio shutdown. Leave the
|
package/hive-notify.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// hive-notify -- tell a person, on Slack or Discord, when an agent in a room is waiting on them.
|
|
2
|
+
//
|
|
3
|
+
// npx -y hivecode-mcp@latest notify "<link>" --webhook <url> --for <name>[,<name>] [--every <minutes>] [--title <text>]
|
|
4
|
+
//
|
|
5
|
+
// WHY. On 10 Oct 2026 a two-agent demo stalled for an hour: one agent waited for its person's OK,
|
|
6
|
+
// the other waited for it, and the person was not looking at the chat. The room knew. Nobody was
|
|
7
|
+
// told. An owner who leaves agents working has to hear about the two things only they can unblock:
|
|
8
|
+
//
|
|
9
|
+
// - a task waiting for their approval sent at once: an agent is stopped until they answer
|
|
10
|
+
// - messages from agents naming them batched, at most one post per --every minutes
|
|
11
|
+
//
|
|
12
|
+
// Neither is sent while that person is in the room (the Control Room counts): they can see it.
|
|
13
|
+
//
|
|
14
|
+
// WHAT IS NEVER SENT: the room's link or full id. On an open relay the id is the key to the room's
|
|
15
|
+
// files, and a Slack channel is not the place for a key. Messages name the room by --title, or by
|
|
16
|
+
// the first characters of its id.
|
|
17
|
+
//
|
|
18
|
+
// IT MUST NOT LOOK ALIVE AND DELIVER NOTHING. It posts once when it starts and refuses to run if
|
|
19
|
+
// that post fails; it says once when it has lost the room and once when it is back.
|
|
20
|
+
//
|
|
21
|
+
// Error codes Q640-Q649 are this module's.
|
|
22
|
+
|
|
23
|
+
import fs from 'fs'
|
|
24
|
+
import os from 'os'
|
|
25
|
+
import path from 'path'
|
|
26
|
+
import crypto from 'crypto'
|
|
27
|
+
import { startSync, parseLink } from './sync.js'
|
|
28
|
+
import { makeNotifier, scrub } from './hive-webhook.js'
|
|
29
|
+
|
|
30
|
+
const oneLine = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t }
|
|
31
|
+
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
32
|
+
const LOST_AFTER = 5 * 60000
|
|
33
|
+
|
|
34
|
+
/** Does `text` address `name` (@Name, not part of a longer word)? @all counts. */
|
|
35
|
+
export function addresses(text, name) {
|
|
36
|
+
const t = String(text || '')
|
|
37
|
+
if (/(^|[^\w@])@all\b/i.test(t)) return true
|
|
38
|
+
return new RegExp(`(^|[^\\w@])@${esc(name)}(?![\\w-])`, 'i').test(t)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What has been sent, kept on disk so a restart does not send it again. */
|
|
42
|
+
export function stateFileFor(room, home = os.homedir()) {
|
|
43
|
+
const h = crypto.createHash('sha256').update(String(room)).digest('hex').slice(0, 16)
|
|
44
|
+
return path.join(home, '.hivecode', 'notify', `${h}.json`)
|
|
45
|
+
}
|
|
46
|
+
export function lockFileFor(room, home = os.homedir()) {
|
|
47
|
+
const h = crypto.createHash('sha256').update(String(room)).digest('hex').slice(0, 16)
|
|
48
|
+
return path.join(home, '.hivecode', 'notify', `${h}.lock`)
|
|
49
|
+
}
|
|
50
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' } }
|
|
51
|
+
|
|
52
|
+
/** One notifier per room. Created exclusively so two starting at the same time cannot double-post. */
|
|
53
|
+
export function takeLock(file) {
|
|
54
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
55
|
+
for (let i = 0; i < 2; i++) {
|
|
56
|
+
try {
|
|
57
|
+
const fd = fs.openSync(file, 'wx')
|
|
58
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }))
|
|
59
|
+
fs.closeSync(fd)
|
|
60
|
+
return () => { try { if (JSON.parse(fs.readFileSync(file, 'utf8')).pid === process.pid) fs.unlinkSync(file) } catch { } }
|
|
61
|
+
} catch (e) {
|
|
62
|
+
if (e.code !== 'EEXIST') throw e
|
|
63
|
+
let held = null
|
|
64
|
+
try { held = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { held = null }
|
|
65
|
+
if (held && held.pid && held.pid !== process.pid && alive(held.pid)) {
|
|
66
|
+
const err = new Error(`Q644 a notifier is already running for this room (process ${held.pid}, since ${held.at}). Use that one, or stop it first.`)
|
|
67
|
+
err.code = 'Q644'
|
|
68
|
+
throw err
|
|
69
|
+
}
|
|
70
|
+
try { fs.unlinkSync(file) } catch { }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const err = new Error(`Q644 could not take ${file}`)
|
|
74
|
+
err.code = 'Q644'
|
|
75
|
+
throw err
|
|
76
|
+
}
|
|
77
|
+
function loadSent(file) {
|
|
78
|
+
try { const j = JSON.parse(fs.readFileSync(file, 'utf8')); if (j && j.tasks) return j } catch { }
|
|
79
|
+
return { tasks: {} }
|
|
80
|
+
}
|
|
81
|
+
function saveSent(file, s) {
|
|
82
|
+
try {
|
|
83
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
84
|
+
const ids = Object.keys(s.tasks)
|
|
85
|
+
if (ids.length > 500) for (const id of ids.slice(0, ids.length - 500)) delete s.tasks[id]
|
|
86
|
+
fs.writeFileSync(file, JSON.stringify(s))
|
|
87
|
+
} catch { /* a notifier that cannot remember sends twice; it still sends */ }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The watching itself, on a live sync handle. `check()` is one pass; the CLI calls it on a timer,
|
|
92
|
+
* and tests call it with a clock of their own.
|
|
93
|
+
*/
|
|
94
|
+
export function makeWatch({ hive, forNames, title, send, now = () => Date.now(), every = 5, stateFile, log = () => {} }) {
|
|
95
|
+
const people = forNames.map((n) => String(n).trim()).filter(Boolean)
|
|
96
|
+
const lc = new Set(people.map((n) => n.toLowerCase()))
|
|
97
|
+
const tasks = hive.doc.getMap('tasks')
|
|
98
|
+
const owners = hive.doc.getMap('owners')
|
|
99
|
+
const chat = hive.doc.getArray('chat')
|
|
100
|
+
const sent = loadSent(stateFile)
|
|
101
|
+
let seenChat = chat.length // what was said before this started is not news
|
|
102
|
+
const buffer = []
|
|
103
|
+
let lastFlush = 0
|
|
104
|
+
let lostSince = 0
|
|
105
|
+
let toldLost = false
|
|
106
|
+
|
|
107
|
+
const here = (name) => (hive.members ? hive.members() : []).some((m) => String(m.name).toLowerCase() === name.toLowerCase() && m.kind === 'human')
|
|
108
|
+
const ownerOf = (t) => String(owners.get(t.to) || '')
|
|
109
|
+
|
|
110
|
+
function waiting() {
|
|
111
|
+
return [...tasks.values()].filter((t) => t && t.status === 'pending' && lc.has(ownerOf(t).toLowerCase()))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function check() {
|
|
115
|
+
const t = now()
|
|
116
|
+
// THE CONNECTION. Lost for five minutes is said once; back is said once.
|
|
117
|
+
const connected = !!(hive.connection ? hive.connection().connected : hive.provider && hive.provider.wsconnected)
|
|
118
|
+
if (!connected) {
|
|
119
|
+
if (!lostSince) lostSince = t
|
|
120
|
+
if (!toldLost && t - lostSince >= LOST_AFTER) {
|
|
121
|
+
toldLost = true
|
|
122
|
+
await send(`Hivecode lost ${title} ${Math.round((t - lostSince) / 60000)} minutes ago, so nothing from it can reach you here. It keeps trying to reconnect.`)
|
|
123
|
+
}
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (lostSince) {
|
|
127
|
+
if (toldLost) await send(`Hivecode is back in ${title}.`)
|
|
128
|
+
lostSince = 0
|
|
129
|
+
toldLost = false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A TASK WAITING FOR APPROVAL: at once, once per task.
|
|
133
|
+
for (const task of waiting()) {
|
|
134
|
+
const owner = ownerOf(task)
|
|
135
|
+
if (sent.tasks[task.id] || here(owner)) continue
|
|
136
|
+
const ok = await send(`Approval needed in ${title}: ${task.by} asked ${task.to} to "${oneLine(task.text, 300)}". ${task.to} waits until ${owner} approves or denies it in the Control Room.`)
|
|
137
|
+
if (ok) {
|
|
138
|
+
sent.tasks[task.id] = t
|
|
139
|
+
saveSent(stateFile, sent)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// MESSAGES NAMING THEM, from agents, while they are away: batched.
|
|
144
|
+
const all = chat.toArray()
|
|
145
|
+
if (all.length < seenChat) seenChat = 0 // the relay restarted and the room's history went with it
|
|
146
|
+
for (const m of all.slice(seenChat)) {
|
|
147
|
+
if (!m || m.kind !== 'ai') continue
|
|
148
|
+
for (const p of people) if (addresses(m.text, p) && !here(p)) { buffer.push({ by: m.by, to: p, text: m.text }); break }
|
|
149
|
+
}
|
|
150
|
+
seenChat = all.length
|
|
151
|
+
if (buffer.length && t - lastFlush >= every * 60000) {
|
|
152
|
+
const shown = buffer.slice()
|
|
153
|
+
const to = [...new Set(shown.map((b) => b.to))].join(', ')
|
|
154
|
+
const lines = shown.slice(0, 5).map((b) => `- ${b.by}: ${oneLine(b.text, 220)}`)
|
|
155
|
+
if (shown.length > 5) lines.push(`- and ${shown.length - 5} more`)
|
|
156
|
+
const ok = await send(`${shown.length} message${shown.length === 1 ? '' : 's'} for ${to} in ${title}:\n${lines.join('\n')}`)
|
|
157
|
+
if (ok) {
|
|
158
|
+
buffer.splice(0, shown.length)
|
|
159
|
+
lastFlush = t
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { check, waiting, buffered: () => buffer.length, sent }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---- CLI -------------------------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
const USAGE = `usage:
|
|
170
|
+
npx -y hivecode-mcp@latest notify "<link>" --webhook <Slack or Discord webhook URL> --for <name>[,<name>]
|
|
171
|
+
|
|
172
|
+
--for <names> whose approvals and mentions to send (the people, not their agents)
|
|
173
|
+
--every <min> at most one post of messages per this many minutes (default 5)
|
|
174
|
+
--title <text> what to call the room in posts (default: the first characters of its id)
|
|
175
|
+
--name <name> the notifier's own name in the room (default Notifier)`
|
|
176
|
+
|
|
177
|
+
export async function main(argv, { log = console.log, logErr = console.error, fetchImpl = globalThis.fetch, home = os.homedir(), now = () => Date.now(), once = false } = {}) {
|
|
178
|
+
if (argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') { log(USAGE); return 0 }
|
|
179
|
+
const SWITCHES = new Set(['dry-run', 'once'])
|
|
180
|
+
const pos = []
|
|
181
|
+
const f = {}
|
|
182
|
+
for (let i = 0; i < argv.length; i++) {
|
|
183
|
+
const a = argv[i]
|
|
184
|
+
if (a.startsWith('--')) {
|
|
185
|
+
const k = a.slice(2)
|
|
186
|
+
if (SWITCHES.has(k)) f[k] = true
|
|
187
|
+
else f[k] = argv[++i] ?? ''
|
|
188
|
+
} else {
|
|
189
|
+
pos.push(a)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const link = pos[0] || ''
|
|
193
|
+
const { relay, room, token } = parseLink(link)
|
|
194
|
+
if (!relay || !room) { logErr(`Q640 "${link}" is not a room link (it looks like wss://relay|room-...).\n\n${USAGE}`); return 2 }
|
|
195
|
+
if (!f.webhook) { logErr(`Q641 say where to post, with --webhook <Slack or Discord incoming-webhook URL>.\n\n${USAGE}`); return 2 }
|
|
196
|
+
const forNames = String(f.for || '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
197
|
+
if (!forNames.length) { logErr(`Q642 say whose waits to report, with --for <name> (the person, as they appear in the room).\n\n${USAGE}`); return 2 }
|
|
198
|
+
const every = Number(f.every || 5)
|
|
199
|
+
if (!(every > 0)) { logErr('Q642 --every must be a positive number of minutes'); return 2 }
|
|
200
|
+
const title = f.title ? oneLine(f.title, 60) : `room ${room.slice(0, 8)}…`
|
|
201
|
+
const send = makeNotifier(f.webhook, { fetchImpl, log })
|
|
202
|
+
|
|
203
|
+
// FIRST, PROVE THE WEBHOOK WORKS. A notifier whose posts go nowhere is worse than none: the person
|
|
204
|
+
// believes they will be told.
|
|
205
|
+
if (send.kind === 'other' && send.host) log(`posting in Slack format to ${send.host}`)
|
|
206
|
+
const okSent = await send(`Hivecode will post here when an agent in ${title} is waiting on ${forNames.join(', ')}.`)
|
|
207
|
+
if (!okSent) { logErr('Q643 the webhook refused the first post (see above). Check the URL, then start again. Nothing is running.'); return 1 }
|
|
208
|
+
|
|
209
|
+
const lockFile = lockFileFor(room, home)
|
|
210
|
+
const releaseLock = (() => { try { return takeLock(lockFile) } catch (e) { logErr(e.message); return null } })()
|
|
211
|
+
if (!releaseLock) return 2
|
|
212
|
+
|
|
213
|
+
const dir = path.join(home, '.hivecode', 'notify', `room-${crypto.createHash('sha256').update(room).digest('hex').slice(0, 12)}`)
|
|
214
|
+
const hive = startSync({ relay, room, dir, name: f.name || 'Notifier', kind: 'ai', token: token || process.env.HIVE_TOKEN || '', syncFiles: false, passive: true, log: () => { } })
|
|
215
|
+
const w = makeWatch({ hive, forNames, title, send, now, every, stateFile: stateFileFor(room, home), log })
|
|
216
|
+
let synced = false
|
|
217
|
+
await new Promise((res) => { const t = setTimeout(res, 15000); hive.provider.on('sync', (s) => { if (s) { synced = true; clearTimeout(t); res() } }) })
|
|
218
|
+
if (!synced && !(hive.provider && hive.provider.wsconnected)) {
|
|
219
|
+
logErr(`NOTE: could not connect to relay at ${relay}. The room may be unreachable or the network down.`)
|
|
220
|
+
}
|
|
221
|
+
const members = (hive.members() || []).filter((m) => m.name !== (f.name || 'Notifier')).map((m) => `${m.name} (${m.kind})`)
|
|
222
|
+
const pending = w.waiting().length
|
|
223
|
+
log(`watching ${title}: ${members.length ? members.join(', ') : 'nobody here yet'}; ${pending} approval(s) waiting now. Ctrl+C stops it.`)
|
|
224
|
+
if (!members.length && !hive.doc.getArray('chat').length) log('NOTE: this room is empty -- no members and no chat. Is the link right?')
|
|
225
|
+
if (once || f['dry-run']) { await w.check(); hive.stop(); releaseLock(); return 0 }
|
|
226
|
+
const timer = setInterval(() => { w.check().catch((e) => log(`check failed: ${e.message}`)) }, 15000)
|
|
227
|
+
const quit = () => { clearInterval(timer); try { hive.stop() } catch { } try { releaseLock() } catch { } process.exit(0) }
|
|
228
|
+
process.on('SIGINT', quit)
|
|
229
|
+
process.on('SIGTERM', quit)
|
|
230
|
+
await w.check()
|
|
231
|
+
return new Promise(() => { }) // runs until stopped
|
|
232
|
+
}
|
|
233
|
+
|
package/hive-pr.js
CHANGED
|
@@ -37,6 +37,7 @@ import { spawnSync } from 'child_process'
|
|
|
37
37
|
import { fileURLToPath } from 'url'
|
|
38
38
|
import { startSync, parseLink } from './sync.js'
|
|
39
39
|
import { accountFor, verdict, explain, DIVERGED } from './hive-pr-staleness.js'
|
|
40
|
+
import { contentHash } from './substrate.js'
|
|
40
41
|
|
|
41
42
|
const argv = process.argv.slice(2)
|
|
42
43
|
const [LINK = '', NAME = 'hive-pr'] = argv.filter((a) => !a.startsWith('--'))
|
|
@@ -76,12 +77,22 @@ function gitRaw(args, opts = {}) {
|
|
|
76
77
|
|
|
77
78
|
/** The authors of a file, newest-landed first, from VERIFIED receipts only.
|
|
78
79
|
* An unverifiable receipt is not evidence of authorship, so it is dropped rather than trusted. */
|
|
79
|
-
|
|
80
|
+
// `roomText`, when given: the author of what the room HOLDS, not of the newest receipt. After a
|
|
81
|
+
// control-room revert the room holds an older version, restored without a receipt, so "newest" named
|
|
82
|
+
// the reverted agent as the author of the code it had just been reverted out of (antigravity-3, 1 Nov).
|
|
83
|
+
export function authorsOf(receipts, verify, roomText) {
|
|
80
84
|
const ok = receipts.filter((r) => r && r.author && (!verify || verify(r)))
|
|
81
85
|
if (!ok.length) return { last: null, contributors: [], first: 0 }
|
|
82
|
-
|
|
86
|
+
// Receipts newer than the one the room holds are work that was reverted out: not co-authors either.
|
|
87
|
+
// Content no receipt matches (a merge, or a revert to a merged state) keeps the old rule, newest
|
|
88
|
+
// lander, a known limit until merged states are stamped into the ledger (antigravity-3, 1 Nov).
|
|
89
|
+
const want = typeof roomText === 'string' ? contentHash(roomText) : null
|
|
90
|
+
let idx = -1
|
|
91
|
+
if (want) for (let i = ok.length - 1; i >= 0; i--) if (ok[i].contentHash === want) { idx = i; break }
|
|
92
|
+
const held = idx >= 0 ? ok.slice(0, idx + 1) : ok
|
|
93
|
+
const last = held[held.length - 1]
|
|
83
94
|
const seen = new Map()
|
|
84
|
-
for (const r of
|
|
95
|
+
for (const r of held) if (!seen.has(r.author)) seen.set(r.author, { author: r.author, name: r.name || 'agent', at: r.at || 0 })
|
|
85
96
|
return {
|
|
86
97
|
last: { author: last.author, name: last.name || 'agent', at: last.at || 0 },
|
|
87
98
|
contributors: [...seen.values()],
|
|
@@ -206,7 +217,9 @@ if (isMain) {
|
|
|
206
217
|
const receipts = hive.provenanceOf(rel) || []
|
|
207
218
|
const audit = hive.verifyProvenanceOf(rel) || { bad: [] }
|
|
208
219
|
const badIdx = new Set((audit.bad || []).map((b) => b.i))
|
|
209
|
-
|
|
220
|
+
let roomText = null
|
|
221
|
+
try { roomText = fs.readFileSync(path.join(dir, rel), 'utf8') } catch { }
|
|
222
|
+
const a = authorsOf(receipts, (r) => !badIdx.has(receipts.indexOf(r)), roomText)
|
|
210
223
|
files.push({ rel, abs: path.join(dir, rel), ...a })
|
|
211
224
|
}
|
|
212
225
|
|