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/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
+ }