hivecode-mcp 0.14.0 → 0.14.2
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 +98 -98
- package/LICENSE +21 -21
- package/README.md +421 -421
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -310
- package/docs/GITHUB_WORKFLOW.md +394 -394
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -157
- package/docs/QUICKSTART.md +266 -266
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +474 -450
- package/docs/TROUBLESHOOTING.md +105 -105
- package/hive-activity.js +336 -336
- package/hive-authors.js +46 -44
- package/hive-await.js +230 -230
- package/hive-branch.js +608 -607
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +2004 -1944
- package/hive-hook-install.js +179 -179
- package/hive-instructions.js +74 -74
- package/hive-land.js +695 -691
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +520 -520
- package/hive-mcp-http.js +591 -580
- package/hive-mcp.js +2156 -2093
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +351 -350
- package/hive-notify.js +234 -233
- package/hive-obligations.js +247 -247
- package/hive-poll-proto.js +50 -50
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +600 -512
- package/hive-privacy.js +327 -327
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +234 -230
- package/hive-repos.js +430 -430
- package/hive-room-key.js +210 -209
- package/hive-room-mode.js +1668 -1668
- package/hive-room-watcher.js +562 -561
- package/hive-secrets.js +89 -89
- package/hive-session.js +114 -114
- package/hive-setup.js +560 -558
- package/hive-slack-agent.js +408 -396
- package/hive-slack.js +2384 -1809
- package/hive-stdio.js +172 -172
- package/hive-stop-hook.js +293 -293
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -71
- package/hive-transport.js +578 -353
- package/hive-update.js +58 -58
- package/hive-version.js +110 -110
- package/hive-webhook.js +55 -54
- package/icr-merge.js +58 -58
- package/icr.js +851 -851
- package/lang-brace.js +305 -305
- package/lang-js.js +651 -651
- package/lang-json.js +211 -211
- package/lang-python.js +588 -588
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -413
- package/package.json +192 -192
- package/reconcile.cjs +621 -614
- package/slack-app-manifest-agent.json +61 -61
- package/slack-app-manifest-inbound.json +36 -36
- package/slack-app-manifest.json +27 -27
- package/substrate.js +252 -252
- package/sync.js +5189 -4994
- package/token.js +201 -201
package/hive-secrets.js
CHANGED
|
@@ -1,89 +1,89 @@
|
|
|
1
|
-
// LIVE CREDENTIALS ARE NOT SENT TO THE ROOM (3 Oct 2026).
|
|
2
|
-
//
|
|
3
|
-
// A file in a room goes to the relay and to every participant's disk. ALWAYS_IGNORE keeps the
|
|
4
|
-
// files that are secrets by NAME at home (.env, *.pem, id_rsa, ...), but a secret inside an
|
|
5
|
-
// ordinary file went out with it: an agent that pastes an API key into config.js to try
|
|
6
|
-
// something shares that key with everyone in the room, and with whoever runs the relay.
|
|
7
|
-
//
|
|
8
|
-
// So a file whose content holds a credential the room does not already have is held on this
|
|
9
|
-
// machine (see sync.js, withheld) until the credential is moved out. Only formats a provider
|
|
10
|
-
// stamps on its keys are matched -- a fixed prefix plus enough random characters -- and private
|
|
11
|
-
// key blocks. Nothing here guesses from entropy alone: a false match holds back real work, and
|
|
12
|
-
// the people who hit it would be right to switch this off.
|
|
13
|
-
//
|
|
14
|
-
// The matched value never leaves this module's callers in any form: not in a log line, not in
|
|
15
|
-
// the room, not in status. Only its kind and its line number do.
|
|
16
|
-
|
|
17
|
-
const PATTERNS = [
|
|
18
|
-
{ kind: 'private key', re: /-----BEGIN (?:[A-Z0-9]+ ){0,3}PRIVATE KEY(?: BLOCK)?-----/g },
|
|
19
|
-
{ kind: 'AWS access key', re: /\b(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b/g },
|
|
20
|
-
{ kind: 'GitHub token', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{60,100})\b/g },
|
|
21
|
-
{ kind: 'GitLab token', re: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
|
|
22
|
-
{ kind: 'Slack token', re: /\bxox[abposr]-[0-9]{6,}-[0-9A-Za-z-]{10,}/g },
|
|
23
|
-
{ kind: 'Slack webhook', re: /hooks\.slack\.com\/services\/T[A-Z0-9]{6,}\/B[A-Z0-9]{6,}\/[A-Za-z0-9]{20,}/g },
|
|
24
|
-
{ kind: 'Stripe live key', re: /\b[rs]k_live_[A-Za-z0-9]{20,}\b/g },
|
|
25
|
-
{ kind: 'OpenAI API key', re: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{16,}T3BlbkFJ[A-Za-z0-9_-]{16,}/g },
|
|
26
|
-
{ kind: 'Anthropic API key', re: /\bsk-ant-(?:api|admin|oat)\d{2}-[A-Za-z0-9_-]{60,}/g },
|
|
27
|
-
{ kind: 'npm token', re: /\bnpm_[A-Za-z0-9]{36}\b/g },
|
|
28
|
-
{ kind: 'Hugging Face token', re: /\bhf_[A-Za-z0-9]{30,40}\b/g },
|
|
29
|
-
{ kind: 'Groq API key', re: /\bgsk_[A-Za-z0-9]{40,60}\b/g },
|
|
30
|
-
{ kind: 'SendGrid API key', re: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g },
|
|
31
|
-
{ kind: 'DigitalOcean token', re: /\bdo[opr]_v1_[a-f0-9]{64}\b/g },
|
|
32
|
-
// .npmrc and friends: a registry password or token written out literally. `${NPM_TOKEN}` is the
|
|
33
|
-
// documented way to keep one out of the file, so a reference to the environment is not a secret.
|
|
34
|
-
{ kind: 'registry credential', re: /(?:^|\n)[ \t]*(?:\/\/[^\s=]+:)?_(?:authToken|auth|password)[ \t]*=[ \t]*(?!\$\{)[^\s'"]{8,}/g },
|
|
35
|
-
]
|
|
36
|
-
|
|
37
|
-
// Documentation and tests are full of keys that look real and are not: AWS's own example id,
|
|
38
|
-
// and placeholders like sk_live_XXXXXXXXXXXXXXXXXXXXXXXX. A provider's key is random, so a match
|
|
39
|
-
// with few distinct characters, or that says it is an example, is a placeholder.
|
|
40
|
-
const PLACEHOLDER = /EXAMPLE|example|XXXX|xxxx|0{8}|1234567890|your[_-]?(?:key|token|secret)/i
|
|
41
|
-
function placeholder(value, kind) {
|
|
42
|
-
if (kind === 'private key' || kind === 'registry credential') return false
|
|
43
|
-
if (PLACEHOLDER.test(value)) return true
|
|
44
|
-
return new Set(value).size < 10
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// A SIGNED ACCESS TOKEN -- a JWT, which is what Hivecode's own room tokens are -- is a person: whoever
|
|
48
|
-
// holds it joins as them, with their access. It is kept out of the room's chat and tasks
|
|
49
|
-
// ({ tokens: true }). It is NOT a reason to hold a FILE back: auth code and its tests are full of
|
|
50
|
-
// sample tokens, most of them expired or fake, and holding each such file would stop real work.
|
|
51
|
-
export const TOKEN_PATTERNS = [
|
|
52
|
-
{ kind: 'access token', re: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{16,}/g },
|
|
53
|
-
]
|
|
54
|
-
|
|
55
|
-
/** Every credential in `text`: [{ kind, line, value }]. `value` is for comparing, never for showing.
|
|
56
|
-
* `tokens: true` adds signed access tokens, for what goes into the room's chat and tasks. */
|
|
57
|
-
export function findSecrets(text, { tokens = false } = {}) {
|
|
58
|
-
const s = String(text || '')
|
|
59
|
-
if (!s) return []
|
|
60
|
-
const out = []
|
|
61
|
-
for (const { kind, re } of tokens ? [...PATTERNS, ...TOKEN_PATTERNS] : PATTERNS) {
|
|
62
|
-
re.lastIndex = 0
|
|
63
|
-
let m
|
|
64
|
-
while ((m = re.exec(s)) !== null) {
|
|
65
|
-
const value = m[0].replace(/^\s+/, '')
|
|
66
|
-
if (!placeholder(value, kind)) {
|
|
67
|
-
const at = m.index + (m[0].length - m[0].replace(/^\s+/, '').length)
|
|
68
|
-
out.push({ kind, line: s.slice(0, at).split('\n').length, value })
|
|
69
|
-
}
|
|
70
|
-
if (m[0].length === 0) re.lastIndex++
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return out.sort((a, b) => a.line - b.line)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Credentials in `local` that `shared` does not already contain: what sharing `local` would leak. */
|
|
77
|
-
export function newSecrets(local, shared) {
|
|
78
|
-
const found = findSecrets(local)
|
|
79
|
-
if (!found.length) return []
|
|
80
|
-
const room = String(shared || '')
|
|
81
|
-
return found.filter((f) => !room.includes(f.value))
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** "an OpenAI API key (line 12)" -- the only form a finding is ever shown in. */
|
|
85
|
-
export function describeSecret(f) {
|
|
86
|
-
if (!f) return ''
|
|
87
|
-
const a = /^(?:[aeiou]|npm\b)/i.test(f.kind) ? 'an' : 'a'
|
|
88
|
-
return `${a} ${f.kind} (line ${f.line})`
|
|
89
|
-
}
|
|
1
|
+
// LIVE CREDENTIALS ARE NOT SENT TO THE ROOM (3 Oct 2026).
|
|
2
|
+
//
|
|
3
|
+
// A file in a room goes to the relay and to every participant's disk. ALWAYS_IGNORE keeps the
|
|
4
|
+
// files that are secrets by NAME at home (.env, *.pem, id_rsa, ...), but a secret inside an
|
|
5
|
+
// ordinary file went out with it: an agent that pastes an API key into config.js to try
|
|
6
|
+
// something shares that key with everyone in the room, and with whoever runs the relay.
|
|
7
|
+
//
|
|
8
|
+
// So a file whose content holds a credential the room does not already have is held on this
|
|
9
|
+
// machine (see sync.js, withheld) until the credential is moved out. Only formats a provider
|
|
10
|
+
// stamps on its keys are matched -- a fixed prefix plus enough random characters -- and private
|
|
11
|
+
// key blocks. Nothing here guesses from entropy alone: a false match holds back real work, and
|
|
12
|
+
// the people who hit it would be right to switch this off.
|
|
13
|
+
//
|
|
14
|
+
// The matched value never leaves this module's callers in any form: not in a log line, not in
|
|
15
|
+
// the room, not in status. Only its kind and its line number do.
|
|
16
|
+
|
|
17
|
+
const PATTERNS = [
|
|
18
|
+
{ kind: 'private key', re: /-----BEGIN (?:[A-Z0-9]+ ){0,3}PRIVATE KEY(?: BLOCK)?-----/g },
|
|
19
|
+
{ kind: 'AWS access key', re: /\b(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b/g },
|
|
20
|
+
{ kind: 'GitHub token', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{60,100})\b/g },
|
|
21
|
+
{ kind: 'GitLab token', re: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
|
|
22
|
+
{ kind: 'Slack token', re: /\bxox[abposr]-[0-9]{6,}-[0-9A-Za-z-]{10,}/g },
|
|
23
|
+
{ kind: 'Slack webhook', re: /hooks\.slack\.com\/services\/T[A-Z0-9]{6,}\/B[A-Z0-9]{6,}\/[A-Za-z0-9]{20,}/g },
|
|
24
|
+
{ kind: 'Stripe live key', re: /\b[rs]k_live_[A-Za-z0-9]{20,}\b/g },
|
|
25
|
+
{ kind: 'OpenAI API key', re: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{16,}T3BlbkFJ[A-Za-z0-9_-]{16,}/g },
|
|
26
|
+
{ kind: 'Anthropic API key', re: /\bsk-ant-(?:api|admin|oat)\d{2}-[A-Za-z0-9_-]{60,}/g },
|
|
27
|
+
{ kind: 'npm token', re: /\bnpm_[A-Za-z0-9]{36}\b/g },
|
|
28
|
+
{ kind: 'Hugging Face token', re: /\bhf_[A-Za-z0-9]{30,40}\b/g },
|
|
29
|
+
{ kind: 'Groq API key', re: /\bgsk_[A-Za-z0-9]{40,60}\b/g },
|
|
30
|
+
{ kind: 'SendGrid API key', re: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g },
|
|
31
|
+
{ kind: 'DigitalOcean token', re: /\bdo[opr]_v1_[a-f0-9]{64}\b/g },
|
|
32
|
+
// .npmrc and friends: a registry password or token written out literally. `${NPM_TOKEN}` is the
|
|
33
|
+
// documented way to keep one out of the file, so a reference to the environment is not a secret.
|
|
34
|
+
{ kind: 'registry credential', re: /(?:^|\n)[ \t]*(?:\/\/[^\s=]+:)?_(?:authToken|auth|password)[ \t]*=[ \t]*(?!\$\{)[^\s'"]{8,}/g },
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
// Documentation and tests are full of keys that look real and are not: AWS's own example id,
|
|
38
|
+
// and placeholders like sk_live_XXXXXXXXXXXXXXXXXXXXXXXX. A provider's key is random, so a match
|
|
39
|
+
// with few distinct characters, or that says it is an example, is a placeholder.
|
|
40
|
+
const PLACEHOLDER = /EXAMPLE|example|XXXX|xxxx|0{8}|1234567890|your[_-]?(?:key|token|secret)/i
|
|
41
|
+
function placeholder(value, kind) {
|
|
42
|
+
if (kind === 'private key' || kind === 'registry credential') return false
|
|
43
|
+
if (PLACEHOLDER.test(value)) return true
|
|
44
|
+
return new Set(value).size < 10
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A SIGNED ACCESS TOKEN -- a JWT, which is what Hivecode's own room tokens are -- is a person: whoever
|
|
48
|
+
// holds it joins as them, with their access. It is kept out of the room's chat and tasks
|
|
49
|
+
// ({ tokens: true }). It is NOT a reason to hold a FILE back: auth code and its tests are full of
|
|
50
|
+
// sample tokens, most of them expired or fake, and holding each such file would stop real work.
|
|
51
|
+
export const TOKEN_PATTERNS = [
|
|
52
|
+
{ kind: 'access token', re: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{16,}/g },
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
/** Every credential in `text`: [{ kind, line, value }]. `value` is for comparing, never for showing.
|
|
56
|
+
* `tokens: true` adds signed access tokens, for what goes into the room's chat and tasks. */
|
|
57
|
+
export function findSecrets(text, { tokens = false } = {}) {
|
|
58
|
+
const s = String(text || '')
|
|
59
|
+
if (!s) return []
|
|
60
|
+
const out = []
|
|
61
|
+
for (const { kind, re } of tokens ? [...PATTERNS, ...TOKEN_PATTERNS] : PATTERNS) {
|
|
62
|
+
re.lastIndex = 0
|
|
63
|
+
let m
|
|
64
|
+
while ((m = re.exec(s)) !== null) {
|
|
65
|
+
const value = m[0].replace(/^\s+/, '')
|
|
66
|
+
if (!placeholder(value, kind)) {
|
|
67
|
+
const at = m.index + (m[0].length - m[0].replace(/^\s+/, '').length)
|
|
68
|
+
out.push({ kind, line: s.slice(0, at).split('\n').length, value })
|
|
69
|
+
}
|
|
70
|
+
if (m[0].length === 0) re.lastIndex++
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out.sort((a, b) => a.line - b.line)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Credentials in `local` that `shared` does not already contain: what sharing `local` would leak. */
|
|
77
|
+
export function newSecrets(local, shared) {
|
|
78
|
+
const found = findSecrets(local)
|
|
79
|
+
if (!found.length) return []
|
|
80
|
+
const room = String(shared || '')
|
|
81
|
+
return found.filter((f) => !room.includes(f.value))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** "an OpenAI API key (line 12)" -- the only form a finding is ever shown in. */
|
|
85
|
+
export function describeSecret(f) {
|
|
86
|
+
if (!f) return ''
|
|
87
|
+
const a = /^(?:[aeiou]|npm\b)/i.test(f.kind) ? 'an' : 'a'
|
|
88
|
+
return `${a} ${f.kind} (line ${f.line})`
|
|
89
|
+
}
|
package/hive-session.js
CHANGED
|
@@ -1,114 +1,114 @@
|
|
|
1
|
-
// hive-session.js -- which agent this is, on disk, for the folder its app started it in.
|
|
2
|
-
//
|
|
3
|
-
// The MCP server records its session in <launch folder>/.hive-mcp-session.json, so a restart rejoins
|
|
4
|
-
// as the same agent; the Stop hook, which runs in the same launch folder, reads it to learn which
|
|
5
|
-
// agent it is guarding.
|
|
6
|
-
//
|
|
7
|
-
// WHY THIS IS A MODULE. Until 9 Oct 2026 that file held ONE agent. Two agents started from one folder
|
|
8
|
-
// -- two Claude Code sessions in one repo, the obvious way to run a branch room -- overwrote each
|
|
9
|
-
// other, and the last writer was the only agent on record. The other one's Stop hook then steered it
|
|
10
|
-
// as the wrong agent (that agent's mail, a listener under that agent's name), and a restart brought
|
|
11
|
-
// it back as the other one. Found by walking the founder flow end to end (branch -> two agents ->
|
|
12
|
-
// pr), where the file also showed up in the developer's `git status`.
|
|
13
|
-
//
|
|
14
|
-
// Now the file keeps every agent started in that folder, each with the pid of the server that wrote
|
|
15
|
-
// it. Its top level is still the latest writer, which is all a Stop hook from before this reads, so an
|
|
16
|
-
// older hook keeps working exactly as it did. Server and hook read it through this one module, so
|
|
17
|
-
// the two cannot drift apart (the merge logic once lived twice, and the copies did).
|
|
18
|
-
import fs from 'fs'
|
|
19
|
-
import path from 'path'
|
|
20
|
-
import { spawnSync } from 'child_process'
|
|
21
|
-
|
|
22
|
-
export const SESSION_FILE_NAME = '.hive-mcp-session.json'
|
|
23
|
-
export const HOOK_STATE_NAME = '.hive-stop-hook.json'
|
|
24
|
-
const STALE_MS = 14 * 24 * 3600 * 1000 // a record whose server is gone and that nobody wrote for two weeks
|
|
25
|
-
|
|
26
|
-
export function pidAlive(pid) {
|
|
27
|
-
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
28
|
-
try { process.kill(pid, 0); return true } catch (e) { return !!e && e.code === 'EPERM' }
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Every agent on record in a session file, by name. A file from before 9 Oct holds one, at its top level. */
|
|
32
|
-
export function readAgents(file) {
|
|
33
|
-
let raw
|
|
34
|
-
try { raw = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return {} }
|
|
35
|
-
if (!raw || typeof raw !== 'object') return {}
|
|
36
|
-
if (raw.agents && typeof raw.agents === 'object') return { ...raw.agents }
|
|
37
|
-
if (raw.name) { const one = { ...raw }; delete one.agents; return { [raw.name]: one } }
|
|
38
|
-
return {}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Write the records back; the top level is `latest` (or the newest record), for a Stop hook from before 9 Oct. */
|
|
42
|
-
export function writeAgents(file, agents, latest = null) {
|
|
43
|
-
const now = Date.now()
|
|
44
|
-
for (const [n, s] of Object.entries(agents)) if (!pidAlive(s.pid) && s.at && now - s.at > STALE_MS) delete agents[n]
|
|
45
|
-
const names = Object.keys(agents)
|
|
46
|
-
if (!names.length) { try { fs.rmSync(file) } catch { } return }
|
|
47
|
-
const top = latest && agents[latest.name] === latest ? latest
|
|
48
|
-
: agents[names.sort((a, b) => (agents[b].at || 0) - (agents[a].at || 0))[0]]
|
|
49
|
-
fs.writeFileSync(file, JSON.stringify({ ...top, agents }))
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** The record this server process owns: the one it wrote; else the one for its conversation; else, after
|
|
53
|
-
* a restart, the only one whose server is gone. Two gone is a guess, and a guess here is exactly how one
|
|
54
|
-
* agent wakes up as another, so it answers null and the agent rejoins by name instead. */
|
|
55
|
-
export function ownRecord(agents, { pid = process.pid, conv = '' } = {}) {
|
|
56
|
-
const all = Object.values(agents)
|
|
57
|
-
const mine = all.find((s) => s.pid === pid) || (conv ? all.find((s) => s.conv === conv) : null)
|
|
58
|
-
if (mine) return mine
|
|
59
|
-
const gone = all.filter((s) => !pidAlive(s.pid))
|
|
60
|
-
return gone.length === 1 ? gone[0] : null
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Which agent a Stop hook is guarding. One on record is that one. With several, the hook's own
|
|
64
|
-
* conversation decides: its id, else its transcript (the hive_join that answered "folder: <dir>",
|
|
65
|
-
* then that folder anywhere in it), else the only one whose server still runs. If none of that
|
|
66
|
-
* tells, null: a hook that speaks as the wrong agent does more harm than one that stays quiet. */
|
|
67
|
-
export function agentForHook(agents, hook = {}, { readTail = tailOf } = {}) {
|
|
68
|
-
const all = Object.values(agents).filter((s) => s && s.dir && s.name)
|
|
69
|
-
if (all.length <= 1) return all[0] || null
|
|
70
|
-
const conv = String(hook.session_id || hook.conversationId || '')
|
|
71
|
-
if (conv) { const byConv = all.find((s) => s.conv && s.conv === conv); if (byConv) return byConv }
|
|
72
|
-
const t = hook.transcript_path ? readTail(hook.transcript_path) : ''
|
|
73
|
-
if (t) {
|
|
74
|
-
// The folder as a transcript line spells it: escaped once as JSON, twice if the text was nested.
|
|
75
|
-
const spell = (dir) => { const once = JSON.stringify(String(dir)).slice(1, -1); return [once, JSON.stringify(once).slice(1, -1)] }
|
|
76
|
-
const where = (s) => {
|
|
77
|
-
const forms = spell(s.dir)
|
|
78
|
-
const joined = Math.max(...forms.map((f) => t.lastIndexOf('folder: ' + f)))
|
|
79
|
-
return joined >= 0 ? [1, joined] : [0, Math.max(...forms.map((f) => t.lastIndexOf(f)))]
|
|
80
|
-
}
|
|
81
|
-
const ranked = all.map((s) => ({ s, w: where(s) })).filter((x) => x.w[1] >= 0)
|
|
82
|
-
.sort((a, b) => b.w[0] - a.w[0] || b.w[1] - a.w[1])
|
|
83
|
-
if (ranked.length) return ranked[0].s
|
|
84
|
-
}
|
|
85
|
-
const live = all.filter((s) => pidAlive(s.pid))
|
|
86
|
-
return live.length === 1 ? live[0] : null
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function tailOf(file, max = 8 * 1024 * 1024) {
|
|
90
|
-
try {
|
|
91
|
-
const size = fs.statSync(file).size
|
|
92
|
-
const n = Math.min(size, max)
|
|
93
|
-
const fd = fs.openSync(file, 'r')
|
|
94
|
-
try { const b = Buffer.alloc(n); fs.readSync(fd, b, 0, n, size - n); return b.toString('utf8') } finally { fs.closeSync(fd) }
|
|
95
|
-
} catch { return '' }
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Keep our files out of `git status` in the launch folder, which is often the developer's own repo:
|
|
99
|
-
* listed in that repo's local exclude file (.git/info/exclude), never in a tracked .gitignore. */
|
|
100
|
-
export function excludeFromGit(dir, names = [SESSION_FILE_NAME, HOOK_STATE_NAME]) {
|
|
101
|
-
try {
|
|
102
|
-
const r = spawnSync('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd: dir, encoding: 'utf8', windowsHide: true, timeout: 5000 })
|
|
103
|
-
if (r.status !== 0 || !String(r.stdout || '').trim()) return false
|
|
104
|
-
const file = path.resolve(dir, String(r.stdout).trim())
|
|
105
|
-
let have = ''
|
|
106
|
-
try { have = fs.readFileSync(file, 'utf8') } catch { }
|
|
107
|
-
const listed = new Set(have.split(/\r?\n/).map((l) => l.trim()))
|
|
108
|
-
const add = names.filter((n) => !listed.has(n) && !listed.has('/' + n))
|
|
109
|
-
if (!add.length) return true
|
|
110
|
-
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
111
|
-
fs.appendFileSync(file, `${have && !have.endsWith('\n') ? '\n' : ''}# Hivecode's local session files\n${add.join('\n')}\n`)
|
|
112
|
-
return true
|
|
113
|
-
} catch { return false }
|
|
114
|
-
}
|
|
1
|
+
// hive-session.js -- which agent this is, on disk, for the folder its app started it in.
|
|
2
|
+
//
|
|
3
|
+
// The MCP server records its session in <launch folder>/.hive-mcp-session.json, so a restart rejoins
|
|
4
|
+
// as the same agent; the Stop hook, which runs in the same launch folder, reads it to learn which
|
|
5
|
+
// agent it is guarding.
|
|
6
|
+
//
|
|
7
|
+
// WHY THIS IS A MODULE. Until 9 Oct 2026 that file held ONE agent. Two agents started from one folder
|
|
8
|
+
// -- two Claude Code sessions in one repo, the obvious way to run a branch room -- overwrote each
|
|
9
|
+
// other, and the last writer was the only agent on record. The other one's Stop hook then steered it
|
|
10
|
+
// as the wrong agent (that agent's mail, a listener under that agent's name), and a restart brought
|
|
11
|
+
// it back as the other one. Found by walking the founder flow end to end (branch -> two agents ->
|
|
12
|
+
// pr), where the file also showed up in the developer's `git status`.
|
|
13
|
+
//
|
|
14
|
+
// Now the file keeps every agent started in that folder, each with the pid of the server that wrote
|
|
15
|
+
// it. Its top level is still the latest writer, which is all a Stop hook from before this reads, so an
|
|
16
|
+
// older hook keeps working exactly as it did. Server and hook read it through this one module, so
|
|
17
|
+
// the two cannot drift apart (the merge logic once lived twice, and the copies did).
|
|
18
|
+
import fs from 'fs'
|
|
19
|
+
import path from 'path'
|
|
20
|
+
import { spawnSync } from 'child_process'
|
|
21
|
+
|
|
22
|
+
export const SESSION_FILE_NAME = '.hive-mcp-session.json'
|
|
23
|
+
export const HOOK_STATE_NAME = '.hive-stop-hook.json'
|
|
24
|
+
const STALE_MS = 14 * 24 * 3600 * 1000 // a record whose server is gone and that nobody wrote for two weeks
|
|
25
|
+
|
|
26
|
+
export function pidAlive(pid) {
|
|
27
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
28
|
+
try { process.kill(pid, 0); return true } catch (e) { return !!e && e.code === 'EPERM' }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every agent on record in a session file, by name. A file from before 9 Oct holds one, at its top level. */
|
|
32
|
+
export function readAgents(file) {
|
|
33
|
+
let raw
|
|
34
|
+
try { raw = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return {} }
|
|
35
|
+
if (!raw || typeof raw !== 'object') return {}
|
|
36
|
+
if (raw.agents && typeof raw.agents === 'object') return { ...raw.agents }
|
|
37
|
+
if (raw.name) { const one = { ...raw }; delete one.agents; return { [raw.name]: one } }
|
|
38
|
+
return {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Write the records back; the top level is `latest` (or the newest record), for a Stop hook from before 9 Oct. */
|
|
42
|
+
export function writeAgents(file, agents, latest = null) {
|
|
43
|
+
const now = Date.now()
|
|
44
|
+
for (const [n, s] of Object.entries(agents)) if (!pidAlive(s.pid) && s.at && now - s.at > STALE_MS) delete agents[n]
|
|
45
|
+
const names = Object.keys(agents)
|
|
46
|
+
if (!names.length) { try { fs.rmSync(file) } catch { } return }
|
|
47
|
+
const top = latest && agents[latest.name] === latest ? latest
|
|
48
|
+
: agents[names.sort((a, b) => (agents[b].at || 0) - (agents[a].at || 0))[0]]
|
|
49
|
+
fs.writeFileSync(file, JSON.stringify({ ...top, agents }))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The record this server process owns: the one it wrote; else the one for its conversation; else, after
|
|
53
|
+
* a restart, the only one whose server is gone. Two gone is a guess, and a guess here is exactly how one
|
|
54
|
+
* agent wakes up as another, so it answers null and the agent rejoins by name instead. */
|
|
55
|
+
export function ownRecord(agents, { pid = process.pid, conv = '' } = {}) {
|
|
56
|
+
const all = Object.values(agents)
|
|
57
|
+
const mine = all.find((s) => s.pid === pid) || (conv ? all.find((s) => s.conv === conv) : null)
|
|
58
|
+
if (mine) return mine
|
|
59
|
+
const gone = all.filter((s) => !pidAlive(s.pid))
|
|
60
|
+
return gone.length === 1 ? gone[0] : null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Which agent a Stop hook is guarding. One on record is that one. With several, the hook's own
|
|
64
|
+
* conversation decides: its id, else its transcript (the hive_join that answered "folder: <dir>",
|
|
65
|
+
* then that folder anywhere in it), else the only one whose server still runs. If none of that
|
|
66
|
+
* tells, null: a hook that speaks as the wrong agent does more harm than one that stays quiet. */
|
|
67
|
+
export function agentForHook(agents, hook = {}, { readTail = tailOf } = {}) {
|
|
68
|
+
const all = Object.values(agents).filter((s) => s && s.dir && s.name)
|
|
69
|
+
if (all.length <= 1) return all[0] || null
|
|
70
|
+
const conv = String(hook.session_id || hook.conversationId || '')
|
|
71
|
+
if (conv) { const byConv = all.find((s) => s.conv && s.conv === conv); if (byConv) return byConv }
|
|
72
|
+
const t = hook.transcript_path ? readTail(hook.transcript_path) : ''
|
|
73
|
+
if (t) {
|
|
74
|
+
// The folder as a transcript line spells it: escaped once as JSON, twice if the text was nested.
|
|
75
|
+
const spell = (dir) => { const once = JSON.stringify(String(dir)).slice(1, -1); return [once, JSON.stringify(once).slice(1, -1)] }
|
|
76
|
+
const where = (s) => {
|
|
77
|
+
const forms = spell(s.dir)
|
|
78
|
+
const joined = Math.max(...forms.map((f) => t.lastIndexOf('folder: ' + f)))
|
|
79
|
+
return joined >= 0 ? [1, joined] : [0, Math.max(...forms.map((f) => t.lastIndexOf(f)))]
|
|
80
|
+
}
|
|
81
|
+
const ranked = all.map((s) => ({ s, w: where(s) })).filter((x) => x.w[1] >= 0)
|
|
82
|
+
.sort((a, b) => b.w[0] - a.w[0] || b.w[1] - a.w[1])
|
|
83
|
+
if (ranked.length) return ranked[0].s
|
|
84
|
+
}
|
|
85
|
+
const live = all.filter((s) => pidAlive(s.pid))
|
|
86
|
+
return live.length === 1 ? live[0] : null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function tailOf(file, max = 8 * 1024 * 1024) {
|
|
90
|
+
try {
|
|
91
|
+
const size = fs.statSync(file).size
|
|
92
|
+
const n = Math.min(size, max)
|
|
93
|
+
const fd = fs.openSync(file, 'r')
|
|
94
|
+
try { const b = Buffer.alloc(n); fs.readSync(fd, b, 0, n, size - n); return b.toString('utf8') } finally { fs.closeSync(fd) }
|
|
95
|
+
} catch { return '' }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Keep our files out of `git status` in the launch folder, which is often the developer's own repo:
|
|
99
|
+
* listed in that repo's local exclude file (.git/info/exclude), never in a tracked .gitignore. */
|
|
100
|
+
export function excludeFromGit(dir, names = [SESSION_FILE_NAME, HOOK_STATE_NAME]) {
|
|
101
|
+
try {
|
|
102
|
+
const r = spawnSync('git', ['rev-parse', '--git-path', 'info/exclude'], { cwd: dir, encoding: 'utf8', windowsHide: true, timeout: 5000 })
|
|
103
|
+
if (r.status !== 0 || !String(r.stdout || '').trim()) return false
|
|
104
|
+
const file = path.resolve(dir, String(r.stdout).trim())
|
|
105
|
+
let have = ''
|
|
106
|
+
try { have = fs.readFileSync(file, 'utf8') } catch { }
|
|
107
|
+
const listed = new Set(have.split(/\r?\n/).map((l) => l.trim()))
|
|
108
|
+
const add = names.filter((n) => !listed.has(n) && !listed.has('/' + n))
|
|
109
|
+
if (!add.length) return true
|
|
110
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
111
|
+
fs.appendFileSync(file, `${have && !have.endsWith('\n') ? '\n' : ''}# Hivecode's local session files\n${add.join('\n')}\n`)
|
|
112
|
+
return true
|
|
113
|
+
} catch { return false }
|
|
114
|
+
}
|