hivecode-mcp 0.6.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 +41 -0
- package/LICENSE +21 -0
- package/MCP.md +77 -0
- package/README.md +137 -0
- package/core.js +174 -0
- package/hive-mcp.js +200 -0
- package/package.json +57 -0
- package/sync.js +606 -0
- package/token.js +181 -0
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hivecode-mcp",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "MCP server that lets any AI coding agent join a Hivecode room — humans and multiple AI agents edit one live codebase with folder-scoped permissions and approval gates.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/GSK7024/livecode",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/GSK7024/livecode/issues"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/GSK7024/livecode"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"mcp",
|
|
17
|
+
"model-context-protocol",
|
|
18
|
+
"ai-agents",
|
|
19
|
+
"multi-agent",
|
|
20
|
+
"claude",
|
|
21
|
+
"collaboration",
|
|
22
|
+
"realtime",
|
|
23
|
+
"yjs"
|
|
24
|
+
],
|
|
25
|
+
"bin": {
|
|
26
|
+
"hivecode-mcp": "./hive-mcp.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"hive-mcp.js",
|
|
30
|
+
"sync.js",
|
|
31
|
+
"core.js",
|
|
32
|
+
"token.js",
|
|
33
|
+
"HIVE_RULES.md",
|
|
34
|
+
"MCP.md",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"start": "node server.js",
|
|
43
|
+
"relay": "node server.js",
|
|
44
|
+
"test": "node test.js"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@anthropic-ai/sdk": "^0.104.2",
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
52
|
+
"ignore": "^7.0.5",
|
|
53
|
+
"ws": "^8.18.0",
|
|
54
|
+
"y-websocket": "^2.0.4",
|
|
55
|
+
"yjs": "^13.6.18"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/sync.js
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
// Hivecode sync engine — the reusable core both the human client (folder.js)
|
|
2
|
+
// and the autonomous agent client (hive-agent.js) run on. This is also the seed
|
|
3
|
+
// of the public SDK: any tool or AI agent can `startSync(...)` to become a
|
|
4
|
+
// first-class participant in a room — no human setup required.
|
|
5
|
+
//
|
|
6
|
+
// startSync({ relay, room, dir, name, kind, log }) -> { doc, provider, stop }
|
|
7
|
+
//
|
|
8
|
+
// `kind` is the participant's identity ('human' | 'ai'). It is set BY WHOEVER
|
|
9
|
+
// STARTS THE CLIENT — a human editor passes 'human', an agent passes 'ai' — so
|
|
10
|
+
// nobody has to manually "declare" anything; the identity is implicit in which
|
|
11
|
+
// client is run.
|
|
12
|
+
//
|
|
13
|
+
// Every change (disk->doc and doc->disk) goes through reconcile(): a 3-way merge
|
|
14
|
+
// against each file's last agreed base, so disjoint edits merge, same-line edits
|
|
15
|
+
// get conflict markers (nobody's work is silently lost), and wholesale rewrites
|
|
16
|
+
// are auto-logged to a shared board (HIVE_BOARD.md) for other agents to read.
|
|
17
|
+
|
|
18
|
+
import fs from 'fs'
|
|
19
|
+
import path from 'path'
|
|
20
|
+
import * as Y from 'yjs'
|
|
21
|
+
import { WebsocketProvider } from 'y-websocket'
|
|
22
|
+
import { WebSocket } from 'ws'
|
|
23
|
+
import ignore from 'ignore'
|
|
24
|
+
import { applyDiff, merge3, summarizeChange, changedRange, hasConflictMarkers } from './core.js'
|
|
25
|
+
import { fileRoom, decodeUnsafe, scopeForRoom, pathAllowed, isSafeRelPath } from './token.js'
|
|
26
|
+
|
|
27
|
+
// Wire-format/protocol version. Bump when a change makes clients incompatible
|
|
28
|
+
// (e.g. the v2 per-file-subdoc model). Broadcast via awareness so peers can warn
|
|
29
|
+
// when someone joins on an incompatible build instead of silently failing to sync.
|
|
30
|
+
const PROTOCOL_VERSION = 2
|
|
31
|
+
|
|
32
|
+
// Never sync these, even if not in .gitignore — secrets and obvious junk. This
|
|
33
|
+
// prevents pushing a teammate's .env / private keys / build output to the room.
|
|
34
|
+
const ALWAYS_IGNORE = [
|
|
35
|
+
'.git/', 'node_modules/',
|
|
36
|
+
'.env', '.env.*', '*.pem', '*.key', '*.pfx', '*.p12', 'id_rsa', 'id_ed25519', '*.keystore',
|
|
37
|
+
'*.log', '.DS_Store', 'Thumbs.db', '*.vsix',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
const IGNORE = new Set(['node_modules', '.git'])
|
|
41
|
+
const MAX_BYTES = 1_000_000
|
|
42
|
+
const BOARD_FILE = 'HIVE_BOARD.md' // generated locally from `board`; never synced as a file
|
|
43
|
+
const CHAT_FILE = 'HIVE_CHAT.md' // generated locally from `chat`; the agents' conversation
|
|
44
|
+
const TASKS_FILE = 'HIVE_TASKS.md' // generated locally from `tasks`; directed work + approvals
|
|
45
|
+
const CONFIG_FILE = '.hive.json' // local rendezvous config (room id); not synced
|
|
46
|
+
const RULES_FILE = 'HIVE_RULES.md' // the law every participant follows; written into every room
|
|
47
|
+
const MEMBERS_FILE = 'HIVE_MEMBERS.md' // live presence: who is in the room right now
|
|
48
|
+
// Generated/coordination files are rendered locally from CRDT state — never
|
|
49
|
+
// synced as ordinary files (that would cause echo loops / conflicts).
|
|
50
|
+
const SKIP = new Set([BOARD_FILE, CHAT_FILE, TASKS_FILE, CONFIG_FILE, RULES_FILE, MEMBERS_FILE])
|
|
51
|
+
|
|
52
|
+
// The hive's law. Auto-written into every room folder so it is ALWAYS present —
|
|
53
|
+
// no setup, no relying on an agent to remember it. The sync layer enforces the
|
|
54
|
+
// hard parts (merge, board); this is the rest, in plain imperative language.
|
|
55
|
+
const HIVE_RULES_TEXT = `# HIVE RULES — read this first. Everyone in this room (human or AI) follows these.
|
|
56
|
+
|
|
57
|
+
You are in a Hivecode room: a shared live workspace where humans and AI agents
|
|
58
|
+
edit ONE project together in real time. These rules keep anyone from destroying
|
|
59
|
+
another's work. The sync layer enforces the hard parts automatically; you do the rest.
|
|
60
|
+
|
|
61
|
+
## Identity
|
|
62
|
+
- You are automatically "human" (you ran the editor) or "ai" (you ran the agent
|
|
63
|
+
client). Nobody declares it — running the client is the declaration.
|
|
64
|
+
|
|
65
|
+
## Know who you are working with
|
|
66
|
+
- Read HIVE_MEMBERS.md — who (humans + AIs) is in the room right now, and how many.
|
|
67
|
+
|
|
68
|
+
## Before you touch a file
|
|
69
|
+
1. Read HIVE_CHAT.md — what is everyone doing right now.
|
|
70
|
+
2. Read HIVE_BOARD.md — which files were just rewritten (and what was touched).
|
|
71
|
+
3. If a file you plan to edit appears there, RE-READ it before changing it.
|
|
72
|
+
|
|
73
|
+
## While you work
|
|
74
|
+
4. ANNOUNCE first: post to chat what you are taking, e.g.
|
|
75
|
+
node hive-say.js <yourName> "taking auth.js: adding login validation"
|
|
76
|
+
5. PREFER SMALL PATCHES — grep to the spot, edit a few lines. Patches from
|
|
77
|
+
different agents merge automatically with no conflict.
|
|
78
|
+
6. AVOID full-file rewrites unless necessary. If you must rewrite, RE-READ the
|
|
79
|
+
file first so you build on the latest code (rewrites are auto-logged for all).
|
|
80
|
+
7. STAY IN YOUR LANE — if someone said "I own X" (e.g. the backend), read X but
|
|
81
|
+
do not edit it; leave that to the owner.
|
|
82
|
+
|
|
83
|
+
## When things collide
|
|
84
|
+
8. If you see <<<<<<< ======= >>>>>>> markers in a file, the system could not
|
|
85
|
+
auto-merge — RESOLVE it: keep the right code, delete the markers. Never ignore
|
|
86
|
+
them or blindly overwrite.
|
|
87
|
+
9. If your edit was merged/reworked, that is normal — re-read and continue.
|
|
88
|
+
|
|
89
|
+
## Talking
|
|
90
|
+
10. Coordinate in chat. ASK before anything destructive (delete, rename, big
|
|
91
|
+
refactor) that touches another participant's area.
|
|
92
|
+
|
|
93
|
+
## Directed work + permission (the asymmetric gate)
|
|
94
|
+
11. AI -> AI is COORDINATION: if another agent hands you work, it is auto-accepted
|
|
95
|
+
and you may proceed. This is how the hive plans and divides work by itself.
|
|
96
|
+
12. A HUMAN directing you is different. If a human who is NOT your owner assigns
|
|
97
|
+
you a task, you do NOT act on it — it stays PENDING until YOUR OWNER approves
|
|
98
|
+
("do it or ignore?"). Your own owner's requests proceed. Act only on tasks
|
|
99
|
+
whose status is 'accepted'.
|
|
100
|
+
13. If you are an MCP agent: loop on hive_wait — it blocks until accepted work
|
|
101
|
+
arrives, then you do it and call hive_complete. No need to poll. A pending
|
|
102
|
+
human request will NOT wake you until your owner approves it.
|
|
103
|
+
|
|
104
|
+
## Mission control (a human can pause/steer you)
|
|
105
|
+
- If you are PAUSED, finish your current step and STOP — do not start new work
|
|
106
|
+
until you are resumed. (MCP agents: hive_wait returns no work while paused.)
|
|
107
|
+
- A human may REASSIGN your focus at any time; treat a fresh directive from your
|
|
108
|
+
owner as the new priority.
|
|
109
|
+
|
|
110
|
+
## When a ping arrives while you are mid-task (interruptions)
|
|
111
|
+
14. hive_wait only checks BETWEEN steps, so a ping never interrupts mid-step.
|
|
112
|
+
Finish your current atomic step first (never abandon half-done work), then
|
|
113
|
+
handle queued coordination. If it's urgent (build broken, blocking others),
|
|
114
|
+
do it now.
|
|
115
|
+
15. ACKNOWLEDGE a ping as soon as you see it so the sender isn't left hanging,
|
|
116
|
+
e.g. say "got it — finishing X (~2 min), then on your fix".
|
|
117
|
+
16. YOU triage: announce do-now vs after-current. Your OWNER can override anytime
|
|
118
|
+
— if your owner says "do it now" or "skip that", that wins.
|
|
119
|
+
|
|
120
|
+
Read → announce → patch → respect lanes → resolve conflicts → talk → triage pings → wait for approval.
|
|
121
|
+
`
|
|
122
|
+
|
|
123
|
+
export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir = '.', name = 'anon', kind = 'human', owner = '', token = '', log = console.log, syncFiles = true }) {
|
|
124
|
+
const ROOT = path.resolve(dir)
|
|
125
|
+
fs.mkdirSync(ROOT, { recursive: true })
|
|
126
|
+
|
|
127
|
+
// Respect .gitignore (+ always-ignore secrets/junk) so we never sync a
|
|
128
|
+
// teammate's .env, keys, or build output. Reloaded by reloadIgnores().
|
|
129
|
+
let ig = ignore()
|
|
130
|
+
function reloadIgnores() {
|
|
131
|
+
ig = ignore().add(ALWAYS_IGNORE)
|
|
132
|
+
try { ig.add(fs.readFileSync(path.join(ROOT, '.gitignore'), 'utf8')) } catch { /* no .gitignore */ }
|
|
133
|
+
}
|
|
134
|
+
reloadIgnores()
|
|
135
|
+
const isIgnored = (relPath) => !!relPath && ig.ignores(relPath)
|
|
136
|
+
|
|
137
|
+
// PARENT doc: the manifest (file registry) + all coordination state. Each FILE
|
|
138
|
+
// lives in its OWN doc, synced at its own room ("<room>␁<path>"). A client only
|
|
139
|
+
// connects to the file-rooms it loads, so per-path access control (Phase 3) is
|
|
140
|
+
// possible — Yjs replicates a whole doc to everyone, so isolation must be by
|
|
141
|
+
// splitting into per-file docs, not by filtering one shared doc.
|
|
142
|
+
const doc = new Y.Doc()
|
|
143
|
+
const manifest = doc.getMap('manifest') // relPath -> 1 (the file registry)
|
|
144
|
+
const board = doc.getMap('board') // relPath -> { by, at, churn, symbols }
|
|
145
|
+
const chat = doc.getArray('chat') // ordered coordination messages { by, kind, at, text }
|
|
146
|
+
const tasks = doc.getMap('tasks') // id -> { id, to, by, text, status, decidedBy, at } directed work
|
|
147
|
+
const owners = doc.getMap('owners') // aiName -> ownerHumanName (who may approve its tasks)
|
|
148
|
+
const controls = doc.getMap('controls') // participantName -> { state: 'running'|'paused', by, at } (mission control)
|
|
149
|
+
// disableBc: do NOT sync peer-to-peer over BroadcastChannel. The relay must be
|
|
150
|
+
// the ONLY path between clients — otherwise two clients on the same machine would
|
|
151
|
+
// sync directly and bypass the relay's access control (auth, path scope, read-only).
|
|
152
|
+
const wsOpts = { WebSocketPolyfill: WebSocket, disableBc: true, params: token ? { token } : undefined }
|
|
153
|
+
const provider = new WebsocketProvider(relay, room, doc, wsOpts)
|
|
154
|
+
provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, v: PROTOCOL_VERSION }) // identity is implicit in the client
|
|
155
|
+
if (kind === 'ai' && owner) owners.set(name, owner) // record who is allowed to approve my tasks
|
|
156
|
+
|
|
157
|
+
// One sub-provider + Y.Doc per file, created on demand. `text` is its content.
|
|
158
|
+
// `synced` flips true after the first relay sync — until then an empty doc means
|
|
159
|
+
// "not pulled yet", NOT "new file", so we must not re-seed it from disk (that
|
|
160
|
+
// would create a second CRDT history and duplicate/garble content on rejoin).
|
|
161
|
+
const fileDocs = new Map() // relPath -> { doc, provider, text, synced }
|
|
162
|
+
function openFile(relPath) {
|
|
163
|
+
let e = fileDocs.get(relPath)
|
|
164
|
+
if (e) return e
|
|
165
|
+
const fdoc = new Y.Doc()
|
|
166
|
+
const fprovider = new WebsocketProvider(relay, fileRoom(room, relPath), fdoc, wsOpts)
|
|
167
|
+
const text = fdoc.getText('content')
|
|
168
|
+
e = { doc: fdoc, provider: fprovider, text, synced: false }
|
|
169
|
+
fileDocs.set(relPath, e)
|
|
170
|
+
text.observe((_ev, txn) => { if (!txn.local) reconcile(relPath, 'remote') }) // remote content edits
|
|
171
|
+
fprovider.on('sync', (s) => { if (s) { e.synced = true; reconcile(relPath, 'remote') } }) // initial pull settled
|
|
172
|
+
return e
|
|
173
|
+
}
|
|
174
|
+
function closeFile(relPath) {
|
|
175
|
+
const e = fileDocs.get(relPath)
|
|
176
|
+
if (!e) return
|
|
177
|
+
try { e.provider.destroy() } catch { }
|
|
178
|
+
try { e.doc.destroy() } catch { }
|
|
179
|
+
fileDocs.delete(relPath)
|
|
180
|
+
}
|
|
181
|
+
const fileText = (relPath) => { const e = fileDocs.get(relPath); return e ? e.text : null }
|
|
182
|
+
|
|
183
|
+
// My own path scope, read from my token (for THIS room). Used so I don't even
|
|
184
|
+
// try to open files I'm not granted — the relay would reject them anyway, but
|
|
185
|
+
// this avoids the churn and keeps out-of-scope files off my disk entirely.
|
|
186
|
+
// undefined = no path restriction (open room or whole-room grant).
|
|
187
|
+
let myPaths
|
|
188
|
+
if (token) { const pl = decodeUnsafe(token); const sc = pl && scopeForRoom(pl, room); myPaths = sc ? sc.paths : undefined }
|
|
189
|
+
// canOpen gates BOTH scope (am I granted this path?) AND safety (is the path
|
|
190
|
+
// safe to write to disk — not a "../" traversal, absolute path, or control-char
|
|
191
|
+
// injection a malicious participant slipped into the shared manifest?).
|
|
192
|
+
const canOpen = (relPath) => isSafeRelPath(relPath) && pathAllowed(myPaths, relPath)
|
|
193
|
+
|
|
194
|
+
const known = new Set()
|
|
195
|
+
const mtimes = new Map()
|
|
196
|
+
const bases = new Map() // last synced content per file (advances on every reconcile)
|
|
197
|
+
const forkBases = new Map() // FORK POINT: what THIS author last saw/authored per file.
|
|
198
|
+
// forkBases advances only on local authorship / first adoption — NOT when a
|
|
199
|
+
// remote change is auto-applied to disk. That distinction is what stops a stale
|
|
200
|
+
// local full-file paste from silently deleting another participant's just-arrived
|
|
201
|
+
// work (it would look like a deliberate deletion against an up-to-date base).
|
|
202
|
+
|
|
203
|
+
const rel = (full) => path.relative(ROOT, full).split(path.sep).join('/')
|
|
204
|
+
function walk(d, acc = []) {
|
|
205
|
+
let entries = []
|
|
206
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { return acc }
|
|
207
|
+
for (const e of entries) {
|
|
208
|
+
if (IGNORE.has(e.name)) continue
|
|
209
|
+
const full = path.join(d, e.name)
|
|
210
|
+
const r = rel(full)
|
|
211
|
+
if (e.isDirectory()) { if (!isIgnored(r + '/')) walk(full, acc) } // prune ignored dirs
|
|
212
|
+
else if (e.isFile()) { if (!isIgnored(r)) acc.push(full) } // skip ignored/secret files
|
|
213
|
+
}
|
|
214
|
+
return acc
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readText(full) {
|
|
218
|
+
try {
|
|
219
|
+
const buf = fs.readFileSync(full)
|
|
220
|
+
if (buf.length > MAX_BYTES || buf.includes(0)) return null
|
|
221
|
+
return buf.toString('utf8')
|
|
222
|
+
} catch { return null }
|
|
223
|
+
}
|
|
224
|
+
function writeToDisk(relPath, content) {
|
|
225
|
+
if (!isSafeRelPath(relPath)) return // never write a path that could escape ROOT (defense in depth)
|
|
226
|
+
const full = path.join(ROOT, relPath)
|
|
227
|
+
fs.mkdirSync(path.dirname(full), { recursive: true })
|
|
228
|
+
fs.writeFileSync(full, content)
|
|
229
|
+
known.add(relPath)
|
|
230
|
+
try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const conflicted = new Set() // files currently holding unresolved <<<<<<< markers
|
|
234
|
+
function reconcile(relPath, origin = 'local') {
|
|
235
|
+
if (SKIP.has(relPath) || isIgnored(relPath)) return // never sync secrets/ignored files
|
|
236
|
+
if (!canOpen(relPath)) return // out-of-scope OR unsafe path (traversal/control char) — never materialize it
|
|
237
|
+
const full = path.join(ROOT, relPath)
|
|
238
|
+
const exists = fs.existsSync(full)
|
|
239
|
+
const disk = exists ? readText(full) : null
|
|
240
|
+
if (exists && disk === null) return // present on disk but binary/too-large to represent — leave it untouched (never clobber with stale doc text)
|
|
241
|
+
// Per-file doc gating: we must know the RELAY's content before touching disk.
|
|
242
|
+
// Until the file-doc has synced, an empty Y.Text means "not pulled yet", not
|
|
243
|
+
// "empty file" — acting now would re-seed from disk and fork the CRDT history
|
|
244
|
+
// (duplicated/garbled content on rejoin). So: open if needed, then defer until
|
|
245
|
+
// the 'sync' handler re-runs us. Once synced, "" genuinely means empty.
|
|
246
|
+
let fe = fileDocs.get(relPath)
|
|
247
|
+
if (!fe) { if (disk === null) return; openFile(relPath); return }
|
|
248
|
+
if (!fe.synced) return
|
|
249
|
+
const docText = fe.text.toString()
|
|
250
|
+
if (docText === '' && !manifest.has(relPath)) {
|
|
251
|
+
// the relay has no copy of this path -> publish ours (a genuinely new file)
|
|
252
|
+
if (disk === null) return
|
|
253
|
+
fe.doc.transact(() => applyDiff(fe.text, disk))
|
|
254
|
+
manifest.set(relPath, 1)
|
|
255
|
+
known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
|
|
256
|
+
try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
const yt = fe.text
|
|
260
|
+
if (disk === null) { writeToDisk(relPath, docText); bases.set(relPath, docText); forkBases.set(relPath, docText); return }
|
|
261
|
+
if (disk === docText) {
|
|
262
|
+
known.add(relPath); bases.set(relPath, disk)
|
|
263
|
+
// disk and doc already agree — a safe fork point, but only adopt it as
|
|
264
|
+
// THIS author's fork on first sight or our own local activity (a remote
|
|
265
|
+
// change landing on disk doesn't mean the local author has seen it).
|
|
266
|
+
if (!forkBases.has(relPath) || origin === 'local') forkBases.set(relPath, disk)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
const base = bases.has(relPath) ? bases.get(relPath) : disk
|
|
270
|
+
const fork = forkBases.has(relPath) ? forkBases.get(relPath) : base
|
|
271
|
+
if (origin === 'local') { noteCoEditing(relPath); markEditing(relPath) } // broadcast activity + warn if someone else is on this file
|
|
272
|
+
|
|
273
|
+
let res, reAdded = false
|
|
274
|
+
if (origin === 'local' && hasConflictMarkers(docText) && !hasConflictMarkers(disk)) {
|
|
275
|
+
// Resolving a conflict: the doc still has <<<<<<< markers and this local
|
|
276
|
+
// write removed them — the author has resolved it. Their clean version wins.
|
|
277
|
+
res = { text: disk, conflict: false }
|
|
278
|
+
} else if (origin === 'local') {
|
|
279
|
+
noteIfRewrite(relPath, fork, disk)
|
|
280
|
+
// THE FIX. Merge a local edit against the FORK POINT (what this author
|
|
281
|
+
// last saw), not the latest doc. If the doc gained lines since the fork
|
|
282
|
+
// that this write does NOT contain, the author was working from a stale
|
|
283
|
+
// copy — merging against the fork re-adds those lines (disjoint) or raises
|
|
284
|
+
// a conflict (overlap) instead of silently deleting another's work.
|
|
285
|
+
const theirsNew = changedRange(fork.split('\n'), docText.split('\n')).newLines.filter((l) => l.length)
|
|
286
|
+
const mineLines = new Set(disk.split('\n'))
|
|
287
|
+
const integrated = theirsNew.every((l) => mineLines.has(l)) // this write already contains the remote change
|
|
288
|
+
if (integrated) {
|
|
289
|
+
res = { text: disk, conflict: false } // author built on the latest — trust it
|
|
290
|
+
} else {
|
|
291
|
+
res = merge3(fork, disk, docText)
|
|
292
|
+
reAdded = !res.conflict && res.text !== disk // we restored remote lines a stale write had dropped
|
|
293
|
+
}
|
|
294
|
+
} else {
|
|
295
|
+
res = merge3(base, disk, docText) // incoming remote change merged into our working copy
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
fileDocs.get(relPath).doc.transact(() => applyDiff(yt, res.text))
|
|
299
|
+
if (res.text !== disk) writeToDisk(relPath, res.text)
|
|
300
|
+
known.add(relPath); bases.set(relPath, res.text)
|
|
301
|
+
if (origin === 'local') forkBases.set(relPath, res.text) // we authored this state; it's our new fork
|
|
302
|
+
else if (!forkBases.get(relPath)) forkBases.set(relPath, res.text) // bootstrap: first real content a joining client receives is its fork
|
|
303
|
+
// Conflict guard: announce a NEW conflict in chat (so everyone — and any
|
|
304
|
+
// agent on hive_wait — is alerted), and announce when it's later resolved.
|
|
305
|
+
const hasMarkers = res.conflict || hasConflictMarkers(res.text)
|
|
306
|
+
if (hasMarkers && !conflicted.has(relPath)) {
|
|
307
|
+
conflicted.add(relPath)
|
|
308
|
+
log(`[${name}] ⚠ merge conflict in ${relPath} — kept BOTH versions with <<<<<<< markers`)
|
|
309
|
+
try { say(`⚠ MERGE CONFLICT in ${relPath} — it has <<<<<<< markers. Whoever owns it: resolve before continuing.`) } catch { }
|
|
310
|
+
} else if (!hasMarkers && conflicted.has(relPath)) {
|
|
311
|
+
conflicted.delete(relPath)
|
|
312
|
+
try { say(`✓ conflict in ${relPath} resolved.`) } catch { }
|
|
313
|
+
} else if (reAdded) {
|
|
314
|
+
log(`[${name}] protected ${relPath}: your edit was based on an older version — re-added changes that arrived since (nobody's work lost)`)
|
|
315
|
+
try { say(`↺ ${relPath}: ${name}'s edit was based on an older copy — kept the changes that landed in between (nothing lost).`) } catch { }
|
|
316
|
+
} else if (!hasMarkers) {
|
|
317
|
+
log(`[${name}] merged ${relPath} (both edits kept)`)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const fmtTime = () => new Date().toTimeString().slice(0, 8)
|
|
322
|
+
function noteIfRewrite(relPath, base, next) {
|
|
323
|
+
if (!base) return
|
|
324
|
+
const s = summarizeChange(base, next)
|
|
325
|
+
if (!s.isRewrite) return
|
|
326
|
+
board.set(relPath, { by: name, at: fmtTime(), churn: `${s.changedLines}/${s.totalLines} lines`, symbols: s.symbols })
|
|
327
|
+
log(`[${name}] board: logged REWRITE of ${relPath} (${s.changedLines}/${s.totalLines} lines; touched ${s.symbols.join(', ') || 'n/a'})`)
|
|
328
|
+
}
|
|
329
|
+
function renderBoard() {
|
|
330
|
+
const out = [
|
|
331
|
+
'# Hive Board — recent full-file rewrites (auto-logged by Hivecode).',
|
|
332
|
+
'# READ THIS before editing a file someone just rewrote, then re-read that file.',
|
|
333
|
+
'',
|
|
334
|
+
]
|
|
335
|
+
const entries = [...board.entries()].map(([file, e]) => ({ file, ...e })).sort((a, b) => (a.at < b.at ? 1 : -1))
|
|
336
|
+
if (!entries.length) out.push('(no rewrites yet — patches and small edits are not listed)')
|
|
337
|
+
for (const e of entries) out.push(`- ${e.at} ${e.by} rewrote \`${e.file}\` (${e.churn}) — touched: ${(e.symbols || []).join(', ') || 'n/a'}`)
|
|
338
|
+
writeToDisk(BOARD_FILE, out.join('\n') + '\n')
|
|
339
|
+
}
|
|
340
|
+
if (syncFiles) board.observe(() => renderBoard())
|
|
341
|
+
|
|
342
|
+
// --- coordination channel: the agents (and humans) talk to each other ---
|
|
343
|
+
// say() posts a message into the shared, ordered chat. Everyone renders the
|
|
344
|
+
// whole conversation to HIVE_CHAT.md locally, so an agent just READS that file
|
|
345
|
+
// to see what others are doing and APPENDS via say() (or `node hive-say.js`).
|
|
346
|
+
function say(text) {
|
|
347
|
+
if (!text) return
|
|
348
|
+
chat.push([{ by: name, kind, at: fmtTime(), text: String(text) }])
|
|
349
|
+
}
|
|
350
|
+
function renderChat() {
|
|
351
|
+
const out = [
|
|
352
|
+
'# Hive Chat — the live coordination channel for everyone in this room.',
|
|
353
|
+
'# Humans and AI agents talk here. Agents: READ this before/while working,',
|
|
354
|
+
'# and announce what you are about to do (e.g. "taking auth.js: add login").',
|
|
355
|
+
'',
|
|
356
|
+
]
|
|
357
|
+
for (const m of chat.toArray()) out.push(`- ${m.at} ${m.by} (${m.kind}): ${m.text}`)
|
|
358
|
+
writeToDisk(CHAT_FILE, out.join('\n') + '\n')
|
|
359
|
+
}
|
|
360
|
+
if (syncFiles) chat.observe(() => renderChat())
|
|
361
|
+
|
|
362
|
+
// --- directed work + permission ---
|
|
363
|
+
// ASYMMETRIC gate (this is the rule that makes a hive both autonomous AND safe):
|
|
364
|
+
// - AI -> AI = COORDINATION. Agents plan and hand work to each other freely;
|
|
365
|
+
// these tasks are auto-accepted so the hive flows without a human in the loop.
|
|
366
|
+
// - owner -> own AI = the owner directing their own agent; auto-accepted.
|
|
367
|
+
// - any OTHER human -> AI = a human telling someone else's agent what to do.
|
|
368
|
+
// This does NOT auto-run. It stays PENDING until that AI's OWNER says
|
|
369
|
+
// "do it or ignore" (decide). The agent acts only on 'accepted' tasks.
|
|
370
|
+
// So agents coordinate among themselves, but a human can never make an agent
|
|
371
|
+
// act without that agent's owner approving it.
|
|
372
|
+
const newId = () => 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
|
373
|
+
function assign(to, text) {
|
|
374
|
+
if (!to || !text) return null
|
|
375
|
+
const id = newId()
|
|
376
|
+
const ownerOf = owners.get(to)
|
|
377
|
+
// auto-accept when it's AI->AI coordination, the target has no owner to ask,
|
|
378
|
+
// or the asker IS the target's owner. Otherwise it needs owner approval.
|
|
379
|
+
const auto = kind === 'ai' || !ownerOf || name === ownerOf
|
|
380
|
+
const status = auto ? 'accepted' : 'pending'
|
|
381
|
+
const decidedBy = auto ? (kind === 'ai' ? `${name} (AI coordination)` : name) : null
|
|
382
|
+
tasks.set(id, { id, to, by: name, byKind: kind, text: String(text), status, decidedBy, at: fmtTime() })
|
|
383
|
+
if (auto) say(`@${to}: ${text} (task ${id} — ${kind === 'ai' ? 'AI coordination, proceeding' : 'from owner, proceeding'})`)
|
|
384
|
+
else say(`@${to}: ${text} (task ${id} — ${ownerOf} must approve: do it or ignore?)`)
|
|
385
|
+
return id
|
|
386
|
+
}
|
|
387
|
+
function decide(id, accept, by = name) {
|
|
388
|
+
const t = tasks.get(id)
|
|
389
|
+
if (!t) return { error: 'no such task' }
|
|
390
|
+
const ownerOf = owners.get(t.to)
|
|
391
|
+
if (ownerOf && by !== ownerOf) return { error: `only ${ownerOf} (owner of ${t.to}) can approve task ${id}` }
|
|
392
|
+
tasks.set(id, { ...t, status: accept ? 'accepted' : 'denied', decidedBy: by })
|
|
393
|
+
say(`task ${id} ${accept ? 'APPROVED' : 'denied'} by ${by}: "${t.text}"`)
|
|
394
|
+
return { ok: true }
|
|
395
|
+
}
|
|
396
|
+
function complete(id, note = '') {
|
|
397
|
+
const t = tasks.get(id); if (!t) return { error: 'no such task' }
|
|
398
|
+
tasks.set(id, { ...t, status: 'done', decidedBy: t.decidedBy })
|
|
399
|
+
say(`task ${id} done by ${name}${note ? ': ' + note : ''}`)
|
|
400
|
+
return { ok: true }
|
|
401
|
+
}
|
|
402
|
+
const myTasks = () => [...tasks.values()].filter((t) => t.to === name)
|
|
403
|
+
|
|
404
|
+
// --- mission control: pause / resume / steer an agent ---
|
|
405
|
+
// A human (or an owner) can PAUSE an agent: it should finish its current step,
|
|
406
|
+
// then stop picking up new work until resumed. Agents honor this via hive_wait
|
|
407
|
+
// (it won't return work while paused) and the HIVE_RULES. Cooperative, not forced
|
|
408
|
+
// — but combined with the task gate it gives a real "stop that agent" control.
|
|
409
|
+
function control(target, state, by = name) {
|
|
410
|
+
if (!target) return { error: 'no target' }
|
|
411
|
+
controls.set(target, { state: state === 'paused' ? 'paused' : 'running', by, at: fmtTime() })
|
|
412
|
+
say(`${by} ${state === 'paused' ? '⏸ paused' : '▶ resumed'} ${target}`)
|
|
413
|
+
return { ok: true }
|
|
414
|
+
}
|
|
415
|
+
const isPaused = (who = name) => { const c = controls.get(who); return !!(c && c.state === 'paused') }
|
|
416
|
+
function renderTasks() {
|
|
417
|
+
const all = [...tasks.values()].sort((a, b) => (a.at < b.at ? 1 : -1))
|
|
418
|
+
const out = ['# Hive Tasks — directed work and approvals.', '# AI->AI = auto-accepted coordination. A non-owner human\'s request to an AI', '# stays PENDING until that AI\'s owner approves (do it or ignore).', '']
|
|
419
|
+
if (!all.length) out.push('(no tasks)')
|
|
420
|
+
for (const t of all) out.push(`- [${t.status}] ${t.id} ${t.by} -> ${t.to}: ${t.text}${t.decidedBy ? ` (by ${t.decidedBy})` : ''}`)
|
|
421
|
+
writeToDisk(TASKS_FILE, out.join('\n') + '\n')
|
|
422
|
+
}
|
|
423
|
+
if (syncFiles) tasks.observe(() => renderTasks())
|
|
424
|
+
|
|
425
|
+
// --- live presence + activity: who is here AND what they're touching right now ---
|
|
426
|
+
// Each client broadcasts the file it is currently editing via awareness, so the
|
|
427
|
+
// whole room (and a watching human) sees "who is on what" live — the control-room
|
|
428
|
+
// view of parallel agents. When you start editing a file someone else is already
|
|
429
|
+
// on, you post a one-time heads-up so collisions get coordinated BEFORE a clobber,
|
|
430
|
+
// not just merged after. Activity decays after EDIT_FRESH_MS of no edits.
|
|
431
|
+
const EDIT_FRESH_MS = 15000
|
|
432
|
+
let myEditing = null
|
|
433
|
+
let editingClearTimer = null
|
|
434
|
+
const warnedAt = new Map() // file -> last time we warned about co-editing it
|
|
435
|
+
const setUserState = () => provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, editing: myEditing || undefined, v: PROTOCOL_VERSION })
|
|
436
|
+
function markEditing(relPath) {
|
|
437
|
+
myEditing = { file: relPath, at: Date.now() }
|
|
438
|
+
setUserState()
|
|
439
|
+
if (editingClearTimer) clearTimeout(editingClearTimer)
|
|
440
|
+
editingClearTimer = setTimeout(() => { myEditing = null; setUserState() }, EDIT_FRESH_MS) // let activity fade
|
|
441
|
+
}
|
|
442
|
+
function coEditors(relPath) {
|
|
443
|
+
const now = Date.now(), others = []
|
|
444
|
+
for (const s of provider.awareness.getStates().values()) {
|
|
445
|
+
const u = s.user
|
|
446
|
+
if (!u || u.name === name) continue
|
|
447
|
+
if (u.editing && u.editing.file === relPath && now - u.editing.at < EDIT_FRESH_MS) others.push(u.name)
|
|
448
|
+
}
|
|
449
|
+
return others
|
|
450
|
+
}
|
|
451
|
+
function noteCoEditing(relPath) {
|
|
452
|
+
const others = coEditors(relPath)
|
|
453
|
+
const now = Date.now()
|
|
454
|
+
if (others.length && now - (warnedAt.get(relPath) || 0) > 30000) {
|
|
455
|
+
warnedAt.set(relPath, now)
|
|
456
|
+
try { say(`⚠ heads-up: ${name} is also editing ${relPath} (with ${others.join(', ')}). Small patches auto-merge; coordinate before a full rewrite.`) } catch { }
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
function memberList() {
|
|
460
|
+
const seen = new Map()
|
|
461
|
+
for (const s of provider.awareness.getStates().values()) {
|
|
462
|
+
if (s.user && s.user.name) seen.set(s.user.name, s.user)
|
|
463
|
+
}
|
|
464
|
+
return [...seen.values()]
|
|
465
|
+
}
|
|
466
|
+
function renderMembers() {
|
|
467
|
+
const us = memberList()
|
|
468
|
+
const now = Date.now()
|
|
469
|
+
const out = ['# Hive Members — who is in this room right now (live).', '', `count: ${us.length}`, '']
|
|
470
|
+
for (const u of us) {
|
|
471
|
+
const ed = u.editing && now - u.editing.at < EDIT_FRESH_MS ? ` — editing ${u.editing.file}` : ''
|
|
472
|
+
out.push(`- ${u.name} (${u.kind})${u.owner ? ' — owned by ' + u.owner : ''}${ed}`)
|
|
473
|
+
}
|
|
474
|
+
writeToDisk(MEMBERS_FILE, out.join('\n') + '\n')
|
|
475
|
+
}
|
|
476
|
+
// Warn (once per peer) when someone joins on an incompatible protocol version —
|
|
477
|
+
// otherwise they'd silently fail to sync and look like a "broken room".
|
|
478
|
+
const versionWarned = new Set()
|
|
479
|
+
function checkVersions() {
|
|
480
|
+
for (const s of provider.awareness.getStates().values()) {
|
|
481
|
+
const u = s.user
|
|
482
|
+
if (!u || u.name === name || u.v === PROTOCOL_VERSION) continue
|
|
483
|
+
if (versionWarned.has(u.name)) continue
|
|
484
|
+
versionWarned.add(u.name)
|
|
485
|
+
const theirs = u.v ? `v${u.v}` : 'an older build'
|
|
486
|
+
log(`[${name}] ⚠ ${u.name} is on ${theirs} (incompatible with v${PROTOCOL_VERSION}) — they won't sync correctly until everyone updates.`)
|
|
487
|
+
try { say(`⚠ ${u.name} is on an incompatible version — files won't sync with them until everyone updates Hivecode.`) } catch { }
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (syncFiles) provider.awareness.on('change', () => { renderMembers(); checkVersions() })
|
|
491
|
+
|
|
492
|
+
function scan() {
|
|
493
|
+
const diskFulls = walk(ROOT)
|
|
494
|
+
const diskRel = new Set(diskFulls.map(rel))
|
|
495
|
+
for (const full of diskFulls) {
|
|
496
|
+
const r = rel(full)
|
|
497
|
+
if (SKIP.has(r)) continue
|
|
498
|
+
let mt
|
|
499
|
+
try { mt = fs.statSync(full).mtimeMs } catch { continue }
|
|
500
|
+
if (mtimes.get(r) === mt && manifest.has(r)) continue
|
|
501
|
+
if (readText(full) === null) continue
|
|
502
|
+
mtimes.set(r, mt)
|
|
503
|
+
reconcile(r, 'local')
|
|
504
|
+
}
|
|
505
|
+
const removed = [...known].filter((r) => !diskRel.has(r) && manifest.has(r))
|
|
506
|
+
if (removed.length) {
|
|
507
|
+
doc.transact(() => { for (const r of removed) manifest.delete(r) })
|
|
508
|
+
for (const r of removed) { closeFile(r); known.delete(r); mtimes.delete(r); bases.delete(r); forkBases.delete(r) }
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Manifest changes from OTHERS: a new path -> open its file-room (its sync pulls
|
|
513
|
+
// the content); a removed path -> tear down + delete the local file.
|
|
514
|
+
if (syncFiles) manifest.observe((ev, txn) => {
|
|
515
|
+
if (txn.local) return
|
|
516
|
+
ev.changes.keys.forEach((change, key) => {
|
|
517
|
+
if (change.action === 'delete') {
|
|
518
|
+
closeFile(key)
|
|
519
|
+
try { fs.rmSync(path.join(ROOT, key)) } catch { }
|
|
520
|
+
known.delete(key); bases.delete(key); forkBases.delete(key); mtimes.delete(key)
|
|
521
|
+
log(`[${name}] <- deleted ${key}`)
|
|
522
|
+
} else if (canOpen(key)) {
|
|
523
|
+
openFile(key)
|
|
524
|
+
}
|
|
525
|
+
})
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
// INSTANT propagation: fs.watch fires on the actual edit, so changes go out in
|
|
529
|
+
// ~tens of ms instead of waiting for the poll. A periodic scan stays as a
|
|
530
|
+
// safety net (and the only mechanism where recursive watch isn't supported).
|
|
531
|
+
let scanTimer = null
|
|
532
|
+
let watcher = null
|
|
533
|
+
let debounce = null
|
|
534
|
+
const onFsEvent = (_evt, fname) => {
|
|
535
|
+
if (fname && String(fname).endsWith('.gitignore')) reloadIgnores()
|
|
536
|
+
if (debounce) return
|
|
537
|
+
debounce = setTimeout(() => { debounce = null; scan() }, 40)
|
|
538
|
+
}
|
|
539
|
+
function startWatch() {
|
|
540
|
+
try { watcher = fs.watch(ROOT, { recursive: true }, onFsEvent) }
|
|
541
|
+
catch { watcher = null } // recursive watch unsupported (some Linux) -> rely on scan
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
provider.on('sync', (s) => {
|
|
545
|
+
if (!s || !syncFiles) return
|
|
546
|
+
writeToDisk(RULES_FILE, HIVE_RULES_TEXT) // the law is always present in the room
|
|
547
|
+
for (const key of manifest.keys()) if (canOpen(key)) openFile(key) // connect to every file I'm granted; each file-room's sync pulls its content
|
|
548
|
+
if (board.size) renderBoard()
|
|
549
|
+
if (chat.length) renderChat()
|
|
550
|
+
if (tasks.size) renderTasks()
|
|
551
|
+
renderMembers()
|
|
552
|
+
scan()
|
|
553
|
+
log(`[${name}] folder sync active on ${ROOT} (room "${room}") as ${kind}. ${manifest.size} files.`)
|
|
554
|
+
if (!scanTimer) {
|
|
555
|
+
startWatch()
|
|
556
|
+
scanTimer = setInterval(scan, watcher ? 2000 : 400) // watch handles fast path; scan is the net
|
|
557
|
+
}
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
return {
|
|
561
|
+
doc,
|
|
562
|
+
provider,
|
|
563
|
+
say, // post a coordination message
|
|
564
|
+
assign, // direct a task at a participant (needs approval if target AI has an owner)
|
|
565
|
+
decide, // approve/deny a task (owner only, if set)
|
|
566
|
+
complete, // mark a task done
|
|
567
|
+
myTasks, // tasks directed at me
|
|
568
|
+
control, // pause/resume an agent (mission control)
|
|
569
|
+
isPaused, // is a participant currently paused?
|
|
570
|
+
controls, // the live control map
|
|
571
|
+
// One entry PER NAME, not per connection: the same user open in two windows
|
|
572
|
+
// (e.g. the editor + the browser Control Room) is ONE member. Merge their
|
|
573
|
+
// fields so we keep whichever connection has an active `editing`/`owner`.
|
|
574
|
+
members: () => {
|
|
575
|
+
const seen = new Map()
|
|
576
|
+
for (const s of provider.awareness.getStates().values()) {
|
|
577
|
+
const u = s.user
|
|
578
|
+
if (!u || !u.name) continue
|
|
579
|
+
const prev = seen.get(u.name) || {}
|
|
580
|
+
seen.set(u.name, { ...prev, ...u, editing: u.editing || prev.editing, owner: u.owner || prev.owner })
|
|
581
|
+
}
|
|
582
|
+
return [...seen.values()]
|
|
583
|
+
},
|
|
584
|
+
stop: () => {
|
|
585
|
+
if (scanTimer) clearInterval(scanTimer)
|
|
586
|
+
if (debounce) clearTimeout(debounce)
|
|
587
|
+
if (editingClearTimer) clearTimeout(editingClearTimer)
|
|
588
|
+
try { watcher && watcher.close() } catch { }
|
|
589
|
+
for (const r of [...fileDocs.keys()]) closeFile(r) // tear down every per-file provider
|
|
590
|
+
try { provider.awareness.setLocalState(null) } catch { } // announce departure now (don't wait for timeout)
|
|
591
|
+
try { provider.destroy() } catch { }
|
|
592
|
+
try { doc.destroy() } catch { }
|
|
593
|
+
},
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Parse a Hivecode join link into { relay, room, token }. Two shapes:
|
|
598
|
+
// "wss://relay|room" (open room — no token)
|
|
599
|
+
// "wss://relay|room|<token>" (secured room — the access token is baked in, so
|
|
600
|
+
// the invitee just pastes the link; no settings)
|
|
601
|
+
// A JWT contains '.' but never '|', and room ids/relay URLs never contain '|',
|
|
602
|
+
// so splitting on '|' is unambiguous.
|
|
603
|
+
export function parseLink(link) {
|
|
604
|
+
if (link && link.includes('|')) { const [r, m, t] = link.split('|'); return { relay: r.trim(), room: (m || '').trim(), token: (t || '').trim() } }
|
|
605
|
+
return { relay: null, room: (link || '').trim(), token: '' }
|
|
606
|
+
}
|