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/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, merge3, summarizeChange, changedRange, hasConflictMarkers } from './core.js'
25
- import { fileRoom, decodeUnsafe, scopeForRoom, pathAllowed, isSafeRelPath } from './token.js'
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
- e = { doc: fdoc, provider: fprovider, text, synced: false }
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 = merge3(fork, disk, docText)
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 = merge3(base, disk, docText) // incoming remote change merged into our working copy
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 (!base) return
432
+ if (base == null || base === next) return
324
433
  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'})`)
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)