hivecode-mcp 0.6.0 → 0.7.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/README.md +36 -24
- package/hive-coord.js +72 -0
- package/hive-mcp.js +15 -0
- package/icr-merge.js +58 -0
- package/icr.js +267 -0
- package/lang-js.js +418 -0
- package/package.json +6 -1
- package/sync.js +287 -14
- package/token.js +20 -0
package/sync.js
CHANGED
|
@@ -21,8 +21,10 @@ import * as Y from 'yjs'
|
|
|
21
21
|
import { WebsocketProvider } from 'y-websocket'
|
|
22
22
|
import { WebSocket } from 'ws'
|
|
23
23
|
import ignore from 'ignore'
|
|
24
|
-
import { applyDiff,
|
|
25
|
-
import {
|
|
24
|
+
import { applyDiff, summarizeChange, changedRange, hasConflictMarkers } from './core.js'
|
|
25
|
+
import { icrMerge3 } from './icr-merge.js' // structure/intent-aware merge; falls back to line merge3
|
|
26
|
+
import { makeCoordinator } from './hive-coord.js' // decentralized claim layer (collision PREVENTION)
|
|
27
|
+
import { fileRoom, decodeUnsafe, scopeForRoom, pathAllowed, writeAllowed, isSafeRelPath } from './token.js'
|
|
26
28
|
|
|
27
29
|
// Wire-format/protocol version. Bump when a change makes clients incompatible
|
|
28
30
|
// (e.g. the v2 per-file-subdoc model). Broadcast via awareness so peers can warn
|
|
@@ -45,9 +47,46 @@ const TASKS_FILE = 'HIVE_TASKS.md' // generated locally from `tasks`; directed w
|
|
|
45
47
|
const CONFIG_FILE = '.hive.json' // local rendezvous config (room id); not synced
|
|
46
48
|
const RULES_FILE = 'HIVE_RULES.md' // the law every participant follows; written into every room
|
|
47
49
|
const MEMBERS_FILE = 'HIVE_MEMBERS.md' // live presence: who is in the room right now
|
|
50
|
+
const AGENTS_FILE = 'HIVE_FOR_AGENTS.md' // how an AI agent opening this folder joins (via .hive.json) + behaves
|
|
48
51
|
// Generated/coordination files are rendered locally from CRDT state — never
|
|
49
52
|
// 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])
|
|
53
|
+
const SKIP = new Set([BOARD_FILE, CHAT_FILE, TASKS_FILE, CONFIG_FILE, RULES_FILE, MEMBERS_FILE, AGENTS_FILE])
|
|
54
|
+
|
|
55
|
+
// Written into every room folder on sync (like the rules). An AI agent that opens
|
|
56
|
+
// this project reads it to learn it's a Hivecode room and how to JOIN — via the
|
|
57
|
+
// .hive.json already in the folder, so no human has to paste a link.
|
|
58
|
+
const AGENT_MANUAL_TEXT = `# Hivecode — for AI agents working in this folder
|
|
59
|
+
|
|
60
|
+
This project is part of a live Hivecode room: humans and AI agents editing the same
|
|
61
|
+
codebase together, in real time, with no git push/pull. If you are an AI assistant
|
|
62
|
+
asked to collaborate here, follow this.
|
|
63
|
+
|
|
64
|
+
## Join the room
|
|
65
|
+
1. If you do NOT have a \`hive_join\` tool and you can run terminal commands, register
|
|
66
|
+
the Hivecode MCP server, then reload so the tools load:
|
|
67
|
+
claude mcp add hivecode -- npx -y hivecode-mcp
|
|
68
|
+
2. Call: hive_join({ dir: ".", name: "<your name>", owner: "<the human you work for>" })
|
|
69
|
+
The room, relay, and token are already saved in .hive.json in this folder, so you
|
|
70
|
+
do NOT need a link — hive_join reads them automatically.
|
|
71
|
+
3. Read the HIVE_RULES it returns, and follow them.
|
|
72
|
+
|
|
73
|
+
## Work the loop
|
|
74
|
+
- hive_read_chat() and hive_read_board() BEFORE editing — see what others are doing.
|
|
75
|
+
- hive_claim("path/to/file") BEFORE you edit a file. If you get it, edit then
|
|
76
|
+
hive_release it. If you DON'T, someone else holds it — pick other work
|
|
77
|
+
(hive_claims() shows what's open). This is how agents avoid colliding.
|
|
78
|
+
- hive_say("taking X: doing Y") to announce intent before you edit.
|
|
79
|
+
- Edit ONLY inside the folders you were granted. Edits merge live; rewrites are logged.
|
|
80
|
+
- Block on hive_wait(); when it returns approved work, do it, then hive_complete(id).
|
|
81
|
+
- A task aimed at you stays PENDING until your owner approves it — act only on 'accepted'.
|
|
82
|
+
|
|
83
|
+
## Rules of the road
|
|
84
|
+
- Stay in your lane — never edit folders you weren't granted.
|
|
85
|
+
- Announce before editing; coordinate in chat; ask before anything destructive in others' areas.
|
|
86
|
+
- If a human PAUSES you, finish your current step and stop until resumed.
|
|
87
|
+
- Resolve <<<<<<< conflict markers properly — never blindly overwrite.
|
|
88
|
+
- A human can ROLL BACK your changes at any time; that's normal — re-read and continue.
|
|
89
|
+
`
|
|
51
90
|
|
|
52
91
|
// The hive's law. Auto-written into every room folder so it is ALWAYS present —
|
|
53
92
|
// no setup, no relying on an agent to remember it. The sync layer enforces the
|
|
@@ -70,6 +109,15 @@ another's work. The sync layer enforces the hard parts automatically; you do the
|
|
|
70
109
|
2. Read HIVE_BOARD.md — which files were just rewritten (and what was touched).
|
|
71
110
|
3. If a file you plan to edit appears there, RE-READ it before changing it.
|
|
72
111
|
|
|
112
|
+
## CLAIM before you edit (this is how the hive avoids collisions)
|
|
113
|
+
- Before editing a file, CLAIM it: call the hive_claim tool with the file path
|
|
114
|
+
(and a short intent). This is the most important coordination step.
|
|
115
|
+
• If you GOT it: edit it, then call hive_release when you finish.
|
|
116
|
+
• If you could NOT get it: someone else holds it — do NOT edit it. Pick other
|
|
117
|
+
work; call hive_claims to see what's open.
|
|
118
|
+
- Claims auto-expire, so a stalled agent never blocks the hive — but always
|
|
119
|
+
hive_release the moment you finish, so others can take it.
|
|
120
|
+
|
|
73
121
|
## While you work
|
|
74
122
|
4. ANNOUNCE first: post to chat what you are taking, e.g.
|
|
75
123
|
node hive-say.js <yourName> "taking auth.js: adding login validation"
|
|
@@ -146,6 +194,17 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
146
194
|
const tasks = doc.getMap('tasks') // id -> { id, to, by, text, status, decidedBy, at } directed work
|
|
147
195
|
const owners = doc.getMap('owners') // aiName -> ownerHumanName (who may approve its tasks)
|
|
148
196
|
const controls = doc.getMap('controls') // participantName -> { state: 'running'|'paused', by, at } (mission control)
|
|
197
|
+
// ROLLBACK metadata index: id -> { id, file, by, at, ts, churn, kind, label }. NO
|
|
198
|
+
// file content here — content is scope-sensitive and lives in each FILE's own doc
|
|
199
|
+
// (its `snap` map), so it inherits that file's access control. The parent only
|
|
200
|
+
// holds the timeline (who changed what, when) — same exposure as the manifest,
|
|
201
|
+
// which already lists every path. Lets a monitor (Control Room) show history.
|
|
202
|
+
const historyMeta = doc.getMap('history')
|
|
203
|
+
// The Hive coordination layer: a stigmergic claim map — region(file) -> {by,intent,at,ttl}.
|
|
204
|
+
// Agents SENSE → FLOW → CLAIM before editing so they don't collide. No central controller;
|
|
205
|
+
// claims evaporate via TTL so a stalled agent never deadlocks the hive.
|
|
206
|
+
const claims = doc.getMap('claims')
|
|
207
|
+
const coordinator = makeCoordinator(claims, name, { ttl: 300000 }) // 5-min claim TTL
|
|
149
208
|
// disableBc: do NOT sync peer-to-peer over BroadcastChannel. The relay must be
|
|
150
209
|
// the ONLY path between clients — otherwise two clients on the same machine would
|
|
151
210
|
// sync directly and bypass the relay's access control (auth, path scope, read-only).
|
|
@@ -165,7 +224,13 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
165
224
|
const fdoc = new Y.Doc()
|
|
166
225
|
const fprovider = new WebsocketProvider(relay, fileRoom(room, relPath), fdoc, wsOpts)
|
|
167
226
|
const text = fdoc.getText('content')
|
|
168
|
-
|
|
227
|
+
// `snap` (in the FILE's own doc, so it's scope-protected with the file) holds
|
|
228
|
+
// restore points: id -> { id, file, by, at, ts, content, churn, kind, label }.
|
|
229
|
+
// `undo` is a per-file UndoManager tracking THIS client's local edits, so a
|
|
230
|
+
// human/agent can undo their own CRDT changes (Level-0 rollback).
|
|
231
|
+
const snap = fdoc.getMap('snap')
|
|
232
|
+
const undo = new Y.UndoManager(text, { captureTimeout: 350 }) // default tracks local-origin txns
|
|
233
|
+
e = { doc: fdoc, provider: fprovider, text, snap, undo, synced: false }
|
|
169
234
|
fileDocs.set(relPath, e)
|
|
170
235
|
text.observe((_ev, txn) => { if (!txn.local) reconcile(relPath, 'remote') }) // remote content edits
|
|
171
236
|
fprovider.on('sync', (s) => { if (s) { e.synced = true; reconcile(relPath, 'remote') } }) // initial pull settled
|
|
@@ -174,6 +239,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
174
239
|
function closeFile(relPath) {
|
|
175
240
|
const e = fileDocs.get(relPath)
|
|
176
241
|
if (!e) return
|
|
242
|
+
try { e.undo && e.undo.destroy() } catch { }
|
|
177
243
|
try { e.provider.destroy() } catch { }
|
|
178
244
|
try { e.doc.destroy() } catch { }
|
|
179
245
|
fileDocs.delete(relPath)
|
|
@@ -184,12 +250,17 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
184
250
|
// try to open files I'm not granted — the relay would reject them anyway, but
|
|
185
251
|
// this avoids the churn and keeps out-of-scope files off my disk entirely.
|
|
186
252
|
// 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 }
|
|
253
|
+
let myPaths, myScope = null
|
|
254
|
+
if (token) { const pl = decodeUnsafe(token); const sc = pl && scopeForRoom(pl, room); myScope = sc || null; myPaths = sc ? sc.paths : undefined }
|
|
189
255
|
// canOpen gates BOTH scope (am I granted this path?) AND safety (is the path
|
|
190
256
|
// safe to write to disk — not a "../" traversal, absolute path, or control-char
|
|
191
257
|
// injection a malicious participant slipped into the shared manifest?).
|
|
192
258
|
const canOpen = (relPath) => isSafeRelPath(relPath) && pathAllowed(myPaths, relPath)
|
|
259
|
+
// canWrite: of the files I can SEE, which may I PUSH (edit/create in the room)?
|
|
260
|
+
// A view-only file (in my read scope but not my writePaths) is pull-only — I keep
|
|
261
|
+
// a local copy synced FROM the room but never publish my changes to it. No token
|
|
262
|
+
// or a full-write grant => everything I can open is writable (back-compat).
|
|
263
|
+
const canWrite = (relPath) => !myScope || writeAllowed(myScope, relPath)
|
|
193
264
|
|
|
194
265
|
const known = new Set()
|
|
195
266
|
const mtimes = new Map()
|
|
@@ -231,6 +302,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
231
302
|
}
|
|
232
303
|
|
|
233
304
|
const conflicted = new Set() // files currently holding unresolved <<<<<<< markers
|
|
305
|
+
const icrWarned = new Map() // relPath -> last ICR semantic warning announced (dedupe)
|
|
234
306
|
function reconcile(relPath, origin = 'local') {
|
|
235
307
|
if (SKIP.has(relPath) || isIgnored(relPath)) return // never sync secrets/ignored files
|
|
236
308
|
if (!canOpen(relPath)) return // out-of-scope OR unsafe path (traversal/control char) — never materialize it
|
|
@@ -238,6 +310,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
238
310
|
const exists = fs.existsSync(full)
|
|
239
311
|
const disk = exists ? readText(full) : null
|
|
240
312
|
if (exists && disk === null) return // present on disk but binary/too-large to represent — leave it untouched (never clobber with stale doc text)
|
|
313
|
+
// View-only file: never PUSH our local change/creation (the relay would drop it
|
|
314
|
+
// anyway — it connects this file-room as a reader). Keep disk in sync with the
|
|
315
|
+
// shared copy if we already have it; a brand-new local-only file just stays local
|
|
316
|
+
// and is never registered in the manifest. Reads (origin !== 'local') flow on.
|
|
317
|
+
if (origin === 'local' && !canWrite(relPath)) {
|
|
318
|
+
const fe2 = fileDocs.get(relPath)
|
|
319
|
+
if (fe2 && fe2.synced) { const dt = fe2.text.toString(); if (dt && dt !== disk) writeToDisk(relPath, dt) }
|
|
320
|
+
return
|
|
321
|
+
}
|
|
241
322
|
// Per-file doc gating: we must know the RELAY's content before touching disk.
|
|
242
323
|
// Until the file-doc has synced, an empty Y.Text means "not pulled yet", not
|
|
243
324
|
// "empty file" — acting now would re-seed from disk and fork the CRDT history
|
|
@@ -254,6 +335,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
254
335
|
manifest.set(relPath, 1)
|
|
255
336
|
known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
|
|
256
337
|
try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
|
|
338
|
+
captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'created' }); snappedBase.add(relPath)
|
|
257
339
|
return
|
|
258
340
|
}
|
|
259
341
|
const yt = fe.text
|
|
@@ -264,6 +346,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
264
346
|
// THIS author's fork on first sight or our own local activity (a remote
|
|
265
347
|
// change landing on disk doesn't mean the local author has seen it).
|
|
266
348
|
if (!forkBases.has(relPath) || origin === 'local') forkBases.set(relPath, disk)
|
|
349
|
+
if (!snappedBase.has(relPath) && disk) { captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'baseline' }); snappedBase.add(relPath) }
|
|
267
350
|
return
|
|
268
351
|
}
|
|
269
352
|
const base = bases.has(relPath) ? bases.get(relPath) : disk
|
|
@@ -288,13 +371,20 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
288
371
|
if (integrated) {
|
|
289
372
|
res = { text: disk, conflict: false } // author built on the latest — trust it
|
|
290
373
|
} else {
|
|
291
|
-
res =
|
|
374
|
+
res = icrMerge3(fork, disk, docText, relPath)
|
|
292
375
|
reAdded = !res.conflict && res.text !== disk // we restored remote lines a stale write had dropped
|
|
293
376
|
}
|
|
294
377
|
} else {
|
|
295
|
-
res =
|
|
378
|
+
res = icrMerge3(base, disk, docText, relPath) // incoming remote change merged into our working copy
|
|
296
379
|
}
|
|
297
380
|
|
|
381
|
+
// Snapshot the state JUST BEFORE this author's edit lands, attributed to them —
|
|
382
|
+
// captured only on local origin so each change is recorded once, by its author.
|
|
383
|
+
if (origin === 'local' && res.text !== docText) {
|
|
384
|
+
const sc = summarizeChange(docText, res.text)
|
|
385
|
+
captureSnapshot(relPath, docText, name, { force: sc.isRewrite, churn: `${sc.changedLines}/${sc.totalLines} lines` })
|
|
386
|
+
lastLocalEdit = relPath
|
|
387
|
+
}
|
|
298
388
|
fileDocs.get(relPath).doc.transact(() => applyDiff(yt, res.text))
|
|
299
389
|
if (res.text !== disk) writeToDisk(relPath, res.text)
|
|
300
390
|
known.add(relPath); bases.set(relPath, res.text)
|
|
@@ -313,32 +403,200 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
313
403
|
} else if (reAdded) {
|
|
314
404
|
log(`[${name}] protected ${relPath}: your edit was based on an older version — re-added changes that arrived since (nobody's work lost)`)
|
|
315
405
|
try { say(`↺ ${relPath}: ${name}'s edit was based on an older copy — kept the changes that landed in between (nothing lost).`) } catch { }
|
|
406
|
+
} else if (!hasMarkers && res.icr === 'rename') {
|
|
407
|
+
log(`[${name}] ICR merged ${relPath} (detected a rename and updated the call sites)`)
|
|
408
|
+
} else if (!hasMarkers && res.icr === 'structural') {
|
|
409
|
+
log(`[${name}] ICR merged ${relPath} structurally (both edits kept, syntax guaranteed)`)
|
|
316
410
|
} else if (!hasMarkers) {
|
|
317
411
|
log(`[${name}] merged ${relPath} (both edits kept)`)
|
|
318
412
|
}
|
|
413
|
+
|
|
414
|
+
// ICR semantic warning: the text merged, but ICR saw a meaning-level problem a line
|
|
415
|
+
// merge can't (e.g. a dangling reference). Surface it once per distinct warning so it
|
|
416
|
+
// isn't lost — this is exactly the class of bug ICR exists to catch.
|
|
417
|
+
if (res.icrWarning && icrWarned.get(relPath) !== res.icrWarning) {
|
|
418
|
+
icrWarned.set(relPath, res.icrWarning)
|
|
419
|
+
log(`[${name}] ⚠ ICR flag in ${relPath} — ${res.icrWarning}`)
|
|
420
|
+
try { say(`⚠ ICR flag in ${relPath}: ${res.icrWarning}. It merged, but review — this is a break a plain merge can't see.`) } catch { }
|
|
421
|
+
} else if (!res.icrWarning && icrWarned.has(relPath)) {
|
|
422
|
+
icrWarned.delete(relPath)
|
|
423
|
+
}
|
|
319
424
|
}
|
|
320
425
|
|
|
321
426
|
const fmtTime = () => new Date().toTimeString().slice(0, 8)
|
|
427
|
+
// Log file activity to the shared board: EVERY meaningful edit (so the live
|
|
428
|
+
// activity feed reflects ongoing work), tagged with whether it was a full-file
|
|
429
|
+
// `rewrite` and a numeric `ts` for ordering. HIVE_BOARD.md stays rewrites-only
|
|
430
|
+
// (see renderBoard) so the "read before editing" coordination doc isn't noisy.
|
|
322
431
|
function noteIfRewrite(relPath, base, next) {
|
|
323
|
-
if (
|
|
432
|
+
if (base == null || base === next) return
|
|
324
433
|
const s = summarizeChange(base, next)
|
|
325
|
-
if (!s.
|
|
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'})`)
|
|
434
|
+
if (!s.changedLines) return
|
|
435
|
+
board.set(relPath, { by: name, at: fmtTime(), ts: Date.now(), churn: `${s.changedLines}/${s.totalLines} lines`, symbols: s.symbols, rewrite: s.isRewrite })
|
|
436
|
+
if (s.isRewrite) log(`[${name}] board: logged REWRITE of ${relPath} (${s.changedLines}/${s.totalLines} lines; touched ${s.symbols.join(', ') || 'n/a'})`)
|
|
328
437
|
}
|
|
329
438
|
function renderBoard() {
|
|
439
|
+
// HIVE_BOARD.md is the coordination doc: full-file REWRITES only (small edits
|
|
440
|
+
// live in the activity feed via the board map, but don't clutter this file).
|
|
441
|
+
const entries = [...board.entries()].map(([file, e]) => ({ file, ...e })).filter((e) => e.rewrite).sort((a, b) => (b.ts || 0) - (a.ts || 0) || (a.at < b.at ? 1 : -1))
|
|
442
|
+
if (!entries.length) return // no rewrites yet — don't create/clutter the board file
|
|
330
443
|
const out = [
|
|
331
444
|
'# Hive Board — recent full-file rewrites (auto-logged by Hivecode).',
|
|
332
445
|
'# READ THIS before editing a file someone just rewrote, then re-read that file.',
|
|
333
446
|
'',
|
|
334
447
|
]
|
|
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
448
|
for (const e of entries) out.push(`- ${e.at} ${e.by} rewrote \`${e.file}\` (${e.churn}) — touched: ${(e.symbols || []).join(', ') || 'n/a'}`)
|
|
338
449
|
writeToDisk(BOARD_FILE, out.join('\n') + '\n')
|
|
339
450
|
}
|
|
340
451
|
if (syncFiles) board.observe(() => renderBoard())
|
|
341
452
|
|
|
453
|
+
// --- rollback: restore points + per-author revert + undo --------------------
|
|
454
|
+
// Every meaningful change captures a RESTORE POINT: the file's content from JUST
|
|
455
|
+
// BEFORE the edit, stored in that file's own doc (scope-safe) and indexed in the
|
|
456
|
+
// parent `history` (metadata only). Restoring is a normal forward edit (applyDiff
|
|
457
|
+
// back to the saved content) — so it propagates live to everyone, merges cleanly,
|
|
458
|
+
// and is itself recoverable. Because each client snapshots only ITS OWN local
|
|
459
|
+
// edits, every restore point is attributed to its true author exactly once.
|
|
460
|
+
const SNAP_KEEP = 30 // max AUTO restore points kept per file (manual ones never evicted)
|
|
461
|
+
const SNAP_MIN_GAP_MS = 4000 // throttle auto-capture per file (fine-grained undo is the editor's job)
|
|
462
|
+
const META_KEEP = 400 // global cap on the parent timeline index
|
|
463
|
+
const lastSnapAt = new Map() // relPath -> ts of last auto-capture
|
|
464
|
+
const snappedBase = new Set() // files we've already floored with a baseline snapshot
|
|
465
|
+
const snapId = () => 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
|
466
|
+
|
|
467
|
+
function pruneMeta() {
|
|
468
|
+
if (historyMeta.size <= META_KEEP) return
|
|
469
|
+
const all = [...historyMeta.values()].filter((e) => e.kind === 'auto').sort((a, b) => (a.ts || 0) - (b.ts || 0))
|
|
470
|
+
const overflow = historyMeta.size - META_KEEP
|
|
471
|
+
if (overflow <= 0) return
|
|
472
|
+
doc.transact(() => { for (let i = 0; i < Math.min(overflow, all.length); i++) historyMeta.delete(all[i].id) })
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Capture `content` as a restore point for relPath, attributed to `byWho`.
|
|
476
|
+
// kind: 'auto' (an ordinary edit), 'base' (a file's floor), 'manual' (a named
|
|
477
|
+
// checkpoint), 'restore' (the pre-restore state, kept so restore is reversible).
|
|
478
|
+
function captureSnapshot(relPath, content, byWho, { force = false, churn = '', kind = 'auto', label = '' } = {}) {
|
|
479
|
+
if (content == null) return null
|
|
480
|
+
const fe = fileDocs.get(relPath); if (!fe) return null
|
|
481
|
+
const now = Date.now()
|
|
482
|
+
if (kind === 'auto' && !force && now - (lastSnapAt.get(relPath) || 0) < SNAP_MIN_GAP_MS) return null
|
|
483
|
+
const snap = fe.snap
|
|
484
|
+
let latest = null
|
|
485
|
+
for (const e of snap.values()) if (!latest || (e.ts || 0) > (latest.ts || 0)) latest = e
|
|
486
|
+
if (latest && latest.content === content) return null // identical to the most recent point — nothing to keep
|
|
487
|
+
const id = snapId()
|
|
488
|
+
const at = fmtTime()
|
|
489
|
+
const pruned = []
|
|
490
|
+
fe.doc.transact(() => {
|
|
491
|
+
snap.set(id, { id, file: relPath, by: byWho, at, ts: now, content, churn, kind, label })
|
|
492
|
+
const autos = [...snap.values()].filter((e) => e.kind === 'auto').sort((a, b) => (a.ts || 0) - (b.ts || 0))
|
|
493
|
+
for (let i = 0; i < autos.length - SNAP_KEEP; i++) { snap.delete(autos[i].id); pruned.push(autos[i].id) }
|
|
494
|
+
})
|
|
495
|
+
if (kind === 'auto') lastSnapAt.set(relPath, now)
|
|
496
|
+
doc.transact(() => {
|
|
497
|
+
historyMeta.set(id, { id, file: relPath, by: byWho, at, ts: now, churn, kind, label }) // metadata only — no content
|
|
498
|
+
for (const pid of pruned) historyMeta.delete(pid) // keep the index in step with evicted content
|
|
499
|
+
})
|
|
500
|
+
pruneMeta()
|
|
501
|
+
return id
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Find which file a restore-point id belongs to (via the index, else by scanning
|
|
505
|
+
// open file docs as a fallback).
|
|
506
|
+
function fileOfSnap(id) {
|
|
507
|
+
const m = historyMeta.get(id)
|
|
508
|
+
if (m && m.file) return m.file
|
|
509
|
+
for (const [rp, fe] of fileDocs) if (fe.snap.has(id)) return rp
|
|
510
|
+
return null
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Restore a file to a saved restore point. It first snapshots the CURRENT state
|
|
514
|
+
// (so the restore can itself be undone), then writes the saved content back as a
|
|
515
|
+
// normal edit — which syncs to everyone and to disk.
|
|
516
|
+
function restore(id, by = name) {
|
|
517
|
+
const relPath = fileOfSnap(id)
|
|
518
|
+
if (!relPath) return { error: 'restore point not found' }
|
|
519
|
+
if (!canOpen(relPath)) return { error: `${relPath} is out of your scope` }
|
|
520
|
+
if (!canWrite(relPath)) return { error: `${relPath} is read-only for you` }
|
|
521
|
+
let fe = fileDocs.get(relPath)
|
|
522
|
+
if (!fe) { openFile(relPath); fe = fileDocs.get(relPath) }
|
|
523
|
+
if (!fe) return { error: 'could not open file' }
|
|
524
|
+
const entry = fe.snap.get(id)
|
|
525
|
+
if (!entry || entry.content == null) return { error: 'restore point content unavailable (it may have aged out)' }
|
|
526
|
+
const current = fe.text.toString()
|
|
527
|
+
if (current === entry.content) return { ok: true, unchanged: true, file: relPath }
|
|
528
|
+
captureSnapshot(relPath, current, by, { force: true, kind: 'restore', label: `before restore to ${entry.at}` })
|
|
529
|
+
fe.doc.transact(() => applyDiff(fe.text, entry.content))
|
|
530
|
+
writeToDisk(relPath, entry.content)
|
|
531
|
+
bases.set(relPath, entry.content); forkBases.set(relPath, entry.content)
|
|
532
|
+
try { say(`↩ ${by} restored ${relPath} to ${entry.at}${entry.by && entry.by !== by ? ` (state before ${entry.by}'s edit)` : ''}.`) } catch { }
|
|
533
|
+
return { ok: true, file: relPath }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Restore one file to its state as of timestamp `ts` (the newest point at/below ts).
|
|
537
|
+
function restoreFileTo(relPath, ts, by = name) {
|
|
538
|
+
const fe = fileDocs.get(relPath)
|
|
539
|
+
if (!fe) return { error: 'file not open' }
|
|
540
|
+
let best = null
|
|
541
|
+
for (const e of fe.snap.values()) if ((e.ts || 0) <= ts && (!best || (e.ts || 0) > (best.ts || 0))) best = e
|
|
542
|
+
if (!best) return { error: 'no restore point at/before that time' }
|
|
543
|
+
return restore(best.id, by)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Revert everything an author did since `sinceTs` (default: all of it): for each
|
|
547
|
+
// file they touched, roll back to the state BEFORE their first edit in the window.
|
|
548
|
+
// Honest limitation: this is checkpoint-granular, so if SOMEONE ELSE also edited
|
|
549
|
+
// the same file after that point, their edits in that file are rolled back too.
|
|
550
|
+
// For the dangerous case — an agent rewriting files — that's exactly right.
|
|
551
|
+
function revertAuthor(who, sinceTs = 0, by = name) {
|
|
552
|
+
if (!who) return { error: 'no author given' }
|
|
553
|
+
const earliest = new Map() // file -> earliest meta authored by `who` in the window
|
|
554
|
+
for (const m of historyMeta.values()) {
|
|
555
|
+
if (m.by !== who || (m.ts || 0) < sinceTs) continue
|
|
556
|
+
const cur = earliest.get(m.file)
|
|
557
|
+
if (!cur || (m.ts || 0) < (cur.ts || 0)) earliest.set(m.file, m)
|
|
558
|
+
}
|
|
559
|
+
const files = []
|
|
560
|
+
for (const [file, m] of earliest) files.push({ file, ...restore(m.id, by) })
|
|
561
|
+
const ok = files.filter((f) => f.ok).length
|
|
562
|
+
if (files.length) { try { say(`⏮ ${by} reverted ${who}'s changes across ${ok}/${files.length} file(s).`) } catch { } }
|
|
563
|
+
return { ok: true, reverted: ok, files }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// The timeline (newest first), read from the parent index. Optional filters.
|
|
567
|
+
function listHistory({ file = null, by = null, limit = 200 } = {}) {
|
|
568
|
+
return [...historyMeta.values()]
|
|
569
|
+
.filter((e) => (!file || e.file === file) && (!by || e.by === by))
|
|
570
|
+
.sort((a, b) => (b.ts || 0) - (a.ts || 0))
|
|
571
|
+
.slice(0, limit)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// A named manual checkpoint of a file's current state (never auto-evicted).
|
|
575
|
+
function checkpoint(relPath, label = '', by = name) {
|
|
576
|
+
const fe = fileDocs.get(relPath); if (!fe) return { error: 'file not open' }
|
|
577
|
+
const id = captureSnapshot(relPath, fe.text.toString(), by, { force: true, kind: 'manual', label: label || `checkpoint by ${by}` })
|
|
578
|
+
return id ? { ok: true, id } : { ok: true, unchanged: true }
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Level-0 undo/redo: a client undoes its OWN recent CRDT edits to a file. (In an
|
|
582
|
+
// editor, native Ctrl-Z already covers your own typing; this also covers edits
|
|
583
|
+
// applied programmatically and keeps the doc, disk, and peers in step.)
|
|
584
|
+
let lastLocalEdit = null
|
|
585
|
+
function undoLast() {
|
|
586
|
+
const rp = lastLocalEdit; const fe = rp && fileDocs.get(rp)
|
|
587
|
+
if (!fe || !fe.undo || !fe.undo.canUndo()) return { error: 'nothing to undo' }
|
|
588
|
+
fe.undo.undo()
|
|
589
|
+
const t = fe.text.toString(); writeToDisk(rp, t); bases.set(rp, t); forkBases.set(rp, t)
|
|
590
|
+
return { ok: true, file: rp }
|
|
591
|
+
}
|
|
592
|
+
function redoLast() {
|
|
593
|
+
const rp = lastLocalEdit; const fe = rp && fileDocs.get(rp)
|
|
594
|
+
if (!fe || !fe.undo || !fe.undo.canRedo()) return { error: 'nothing to redo' }
|
|
595
|
+
fe.undo.redo()
|
|
596
|
+
const t = fe.text.toString(); writeToDisk(rp, t); bases.set(rp, t); forkBases.set(rp, t)
|
|
597
|
+
return { ok: true, file: rp }
|
|
598
|
+
}
|
|
599
|
+
|
|
342
600
|
// --- coordination channel: the agents (and humans) talk to each other ---
|
|
343
601
|
// say() posts a message into the shared, ordered chat. Everyone renders the
|
|
344
602
|
// whole conversation to HIVE_CHAT.md locally, so an agent just READS that file
|
|
@@ -544,6 +802,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
544
802
|
provider.on('sync', (s) => {
|
|
545
803
|
if (!s || !syncFiles) return
|
|
546
804
|
writeToDisk(RULES_FILE, HIVE_RULES_TEXT) // the law is always present in the room
|
|
805
|
+
writeToDisk(AGENTS_FILE, AGENT_MANUAL_TEXT) // so an agent opening this folder learns how to join + behave
|
|
547
806
|
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
807
|
if (board.size) renderBoard()
|
|
549
808
|
if (chat.length) renderChat()
|
|
@@ -568,6 +827,20 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
568
827
|
control, // pause/resume an agent (mission control)
|
|
569
828
|
isPaused, // is a participant currently paused?
|
|
570
829
|
controls, // the live control map
|
|
830
|
+
// --- hive coordination: claim a region BEFORE editing (collision prevention) ---
|
|
831
|
+
claim: (region, intent) => coordinator.acquire(String(region), String(intent || 'edit')), // true iff you hold it
|
|
832
|
+
release: (region) => coordinator.release(String(region)),
|
|
833
|
+
senseClaim: (region) => coordinator.sense(String(region)), // the live claim on a region, or null
|
|
834
|
+
claimsBoard: () => coordinator.board(), // everything currently claimed
|
|
835
|
+
// --- rollback ---
|
|
836
|
+
listHistory, // the restore-point timeline (newest first; filter by file/by)
|
|
837
|
+
restore, // restore a file to a restore point by id (reversible)
|
|
838
|
+
restoreFileTo, // restore one file to its state as of a timestamp
|
|
839
|
+
revertAuthor, // roll back everything an author did since a time
|
|
840
|
+
checkpoint, // save a named restore point of a file's current state
|
|
841
|
+
captureSnapshot, // (low-level) capture a restore point
|
|
842
|
+
undoLast, // undo this client's last local edit
|
|
843
|
+
redoLast, // redo it
|
|
571
844
|
// One entry PER NAME, not per connection: the same user open in two windows
|
|
572
845
|
// (e.g. the editor + the browser Control Room) is ONE member. Merge their
|
|
573
846
|
// fields so we keep whichever connection has an active `editing`/`owner`.
|
package/token.js
CHANGED
|
@@ -146,6 +146,26 @@ export function pathAllowed(globs, relPath) {
|
|
|
146
146
|
return inAllow && !matches(neg)
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// Phase 5: within a scope, which VISIBLE paths are also WRITABLE. `writePaths` is
|
|
150
|
+
// an optional subset of `paths`: a file the scope can see is read-only unless it
|
|
151
|
+
// ALSO matches writePaths. This is what lets one agent read frontend/ for context
|
|
152
|
+
// but only edit backend/. Semantics, chosen to fail safe:
|
|
153
|
+
// - role 'reader' -> never writable (whole grant is view-only)
|
|
154
|
+
// - no writePaths key -> every visible path is writable (back-compat: a plain
|
|
155
|
+
// writer/agent edits everything it can see)
|
|
156
|
+
// - writePaths: [] -> nothing writable (explicit view-only everywhere)
|
|
157
|
+
// - writePaths: [globs] -> writable IFF the path matches the globs
|
|
158
|
+
// The relay enforces this by connecting an out-of-write-scope file-room as a
|
|
159
|
+
// reader (so makeReadOnly drops its writes); clients use it for read-only UX.
|
|
160
|
+
export function writeAllowed(scope, relPath) {
|
|
161
|
+
if (!scope) return false
|
|
162
|
+
if ((scope.role || 'writer') === 'reader') return false
|
|
163
|
+
const wp = scope.writePaths
|
|
164
|
+
if (wp == null) return true
|
|
165
|
+
if (!Array.isArray(wp) || wp.length === 0) return false
|
|
166
|
+
return pathAllowed(wp, relPath)
|
|
167
|
+
}
|
|
168
|
+
|
|
149
169
|
// --- self-certifying secured rooms (no server-side key registry, no secret files) ---
|
|
150
170
|
// A secured room's id embeds a fingerprint of the OWNER's public key:
|
|
151
171
|
// hs_<fp>_<rand> fp = base64url(sha256(spki-DER)).slice(0, 22)
|