hivecode-mcp 0.11.1 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ # HIVE RULES — read this first. Everyone in this room (human or AI) follows these.
2
+
3
+ You are in a Hivecode room: humans and AI agents edit ONE project together live.
4
+ These rules keep anyone from destroying another's work. The sync layer enforces
5
+ the hard parts automatically; you do the rest.
6
+
7
+ ## Before you touch a file
8
+ 1. Read HIVE_CHAT.md — what is everyone doing right now.
9
+ 2. Read HIVE_BOARD.md — which files were just rewritten.
10
+ 3. If a file you plan to edit appears there, RE-READ it before changing it.
11
+
12
+ ## While you work
13
+ 4. ANNOUNCE in chat what you are taking before you start.
14
+ 5. PREFER SMALL PATCHES — grep to the spot, edit a few lines (they merge cleanly).
15
+ 6. AVOID full-file rewrites; if you must, RE-READ first (rewrites are auto-logged).
16
+ 7. STAY IN YOUR LANE — read others' areas, but leave edits to the owner.
17
+
18
+ ## When things collide
19
+ 8. <<<<<<< ======= >>>>>>> markers = a real conflict: keep the right code, delete
20
+ the markers. Never ignore or blindly overwrite.
21
+ 9. If your edit was merged/reworked, that is normal — re-read and continue.
22
+
23
+ ## Talking
24
+ 10. Coordinate in chat. ASK before anything destructive in another's area.
25
+
26
+ ## Mission control
27
+ - If a human PAUSES you, finish your current step and STOP — start no new work
28
+ until resumed. A human may reassign your focus anytime; their directive wins.
29
+
30
+ ## When you get a ping mid-task (interruptions)
31
+ 11. Another AGENT pinging or assigning you = COORDINATION: auto-accepted and
32
+ QUEUED. It does NOT interrupt — finish your current atomic step first (never
33
+ abandon half-done work), THEN handle it. If it's urgent (build broken,
34
+ blocking others), do it now.
35
+ 12. ACKNOWLEDGE the moment you see a ping so the sender isn't left hanging, e.g.
36
+ "got it — finishing X (~2 min), then on your auth.js fix".
37
+ 13. YOU triage: say whether you'll do it now or after your current step. Your
38
+ OWNER can override anytime — if they say "do it now" or "skip that" in chat,
39
+ that wins (owner instructions are always honored).
40
+ 14. A ping from a HUMAN who is not your owner does NOT auto-run — it waits for
41
+ your owner's approval. Keep working; your owner decides.
package/docs/MCP.md ADDED
@@ -0,0 +1,77 @@
1
+ # Hivecode MCP server
2
+
3
+ Lets any MCP-capable AI agent (Claude Code, Claude Desktop, etc.) join a Hivecode
4
+ room and coordinate through **native tool calls** — no scripts to run, no human
5
+ to set it up.
6
+
7
+ ## Register it
8
+
9
+ Add Hivecode to your MCP client config. The command runs this repo's server.
10
+
11
+ **Claude Code** (`.mcp.json` in your project, or user settings):
12
+ ```json
13
+ {
14
+ "mcpServers": {
15
+ "hivecode": {
16
+ "command": "node",
17
+ "args": ["C:/Users/G1/Desktop/N/hive-mcp.js"],
18
+ "env": { "HIVE_RELAY": "wss://livecode-xoss.onrender.com" }
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ `HIVE_RELAY` is optional (defaults to the hosted relay).
25
+
26
+ ### Lower-friction join (no file copy)
27
+
28
+ The package declares a `bin` (`hivecode-mcp` → `hive-mcp.js`), so once it's
29
+ published to npm the other agent needs no local copy/clone — just:
30
+
31
+ ```json
32
+ { "mcpServers": { "hivecode": { "command": "npx", "args": ["-y", "hivecode-mcp"] } } }
33
+ ```
34
+
35
+ `npx` fetches and runs it. (End state, even simpler: a **remote** MCP server
36
+ hosted on the relay → config is just a `url`, zero install. Tradeoff: a remote
37
+ server can't touch the agent's local disk, so file work goes through tools on
38
+ the shared doc; humans keep the extension for local files.)
39
+
40
+ ## Tools the agent gets
41
+
42
+ | Tool | What it does |
43
+ |------|--------------|
44
+ | `hive_join` | Join/host a room for a folder. Auto-joins as an **AI** participant. Returns the room info **and the HIVE_RULES to follow**. |
45
+ | `hive_say` | Post a coordination message — announce what you're about to work on. |
46
+ | `hive_read_chat` | Read the room conversation (read this to coordinate). |
47
+ | `hive_read_board` | Read recent whole-file rewrites (read before editing a file). |
48
+ | `hive_members` | Who's in the room (humans + agents). |
49
+ | `hive_status` | Current session info. |
50
+ | `hive_leave` | Leave the room. |
51
+
52
+ ## How an agent uses it
53
+
54
+ 1. `hive_join` (with a `link`, a `dir` containing `.hive.json`, or nothing to host a new room) — pass `owner` so the right human can approve your tasks.
55
+ 2. Read the returned **HIVE_RULES**.
56
+ 3. `hive_read_chat` + `hive_read_board` before touching files.
57
+ 4. `hive_say` to announce intent, then edit files in the synced folder.
58
+ 5. Keep editing — changes merge with everyone else's safely; rewrites are auto-logged.
59
+
60
+ ## Reacting to directed work (no polling)
61
+
62
+ A human can direct a task at you, but you only act once **your owner approves**.
63
+ Don't poll — **block** on `hive_wait`:
64
+
65
+ ```
66
+ loop:
67
+ result = hive_wait(timeoutSeconds: 60) # returns the instant your owner approves
68
+ if result has APPROVED WORK:
69
+ do it, then hive_complete(id)
70
+ # else just call hive_wait again
71
+ ```
72
+
73
+ `hive_wait` returns within ~1s of the approval (it reacts to the live update),
74
+ and also wakes on new chat so you stay responsive to teammates.
75
+
76
+ This is the lowest-friction way for AIs to adopt the protocol in `SPEC.md`:
77
+ the agent never runs a command, it just calls tools.
package/grm-link.cjs ADDED
@@ -0,0 +1,141 @@
1
+ // Gives a Hivecode room a MEMORY, by handing its history to GRM.
2
+ //
3
+ // A room already records everything worth remembering -- HIVE_CHAT.md is the participants'
4
+ // conversation, HIVE_BOARD.md is what was rewritten and by whom, HIVE_TASKS.md is the
5
+ // directed work and its approvals. All of it is written to the room folder as ordinary
6
+ // markdown. And GRM already indexes named files from `code_roots.txt`, re-read on every
7
+ // watcher pass, so a path added here takes effect without restarting anything.
8
+ //
9
+ // So the two halves need no new format, daemon or protocol between them: name those files,
10
+ // and an agent that joins the room tomorrow can ask what was decided in it last week --
11
+ // including in conversations it was never present for. Neither product can do that alone.
12
+ //
13
+ // CommonJS on purpose. sync.js is ESM and the VS Code extension is CJS; a .cjs file is the
14
+ // one shape both can load, so this exists ONCE. The duplicated reconcile()/HIVE_RULES text
15
+ // across those two files has already drifted twice and cost real bugs -- not repeating it.
16
+
17
+ const fs = require('fs')
18
+ const os = require('os')
19
+ const path = require('path')
20
+ const crypto = require('crypto')
21
+
22
+ // The room's own record. NOT the room folder: `code_roots.txt` carries a warning written
23
+ // after adding one directory appended 90,215 blocks in a single pass and had to be cut back
24
+ // out of the journal -- "a root is a whole repository unless you check". Naming four files
25
+ // gives the decision history and nothing else.
26
+ const HISTORY_FILES = ['HIVE_CHAT.md', 'HIVE_BOARD.md', 'HIVE_TASKS.md', 'HIVE_MEMBERS.md']
27
+
28
+ function grmHome() {
29
+ const env = (process.env.GRM_HOME || '').trim()
30
+ return env || path.join(os.homedir(), '.grm')
31
+ }
32
+
33
+ function rootsPath() {
34
+ return path.join(grmHome(), 'code_roots.txt')
35
+ }
36
+
37
+ // Keyed on the resolved directory so the same room in two folders gets two blocks, and the
38
+ // same folder rejoined a hundred times keeps exactly one.
39
+ function tagFor(dir) {
40
+ return 'hivecode:' + crypto.createHash('sha256').update(path.resolve(dir)).digest('hex').slice(0, 12)
41
+ }
42
+
43
+ // The file is written by GRM's installer with a UTF-8 BOM. GRM's own reader strips it, but
44
+ // this must not *drop* it on the way through -- the file belongs to GRM, and a tool that
45
+ // silently reformats another tool's config is how you get a bug nobody can find.
46
+ function readRoots(p) {
47
+ const raw = fs.readFileSync(p, 'utf8')
48
+ return raw.charCodeAt(0) === 0xfeff ? { bom: '', body: raw.slice(1) } : { bom: '', body: raw }
49
+ }
50
+
51
+ function writeRootsAtomic(p, bom, body) {
52
+ const tmp = p + '.hivecode.tmp'
53
+ fs.writeFileSync(tmp, bom + body, 'utf8')
54
+ fs.renameSync(tmp, p) // the watcher re-reads on every pass; never let it see a half-written file
55
+ }
56
+
57
+ // Drop any block previously written for this room. Returns the body without it.
58
+ function stripBlock(body, tag) {
59
+ const lines = body.split(/\r?\n/)
60
+ const out = []
61
+ let inside = false
62
+ for (const line of lines) {
63
+ if (!inside && line.startsWith('# >>> ' + tag)) { inside = true; continue }
64
+ if (inside) { if (line.startsWith('# <<< ' + tag)) inside = false; continue }
65
+ out.push(line)
66
+ }
67
+ return out.join('\n')
68
+ }
69
+
70
+ function blockFor(dir, tag, files) {
71
+ const head = `# >>> ${tag} Hivecode room at ${path.resolve(dir)}`
72
+ const why = '# Added by Hivecode so this room\'s decisions are in memory. Safe to delete.'
73
+ const tail = `# <<< ${tag}`
74
+ return [head, why, ...files, tail].join('\n')
75
+ }
76
+
77
+ /**
78
+ * Point GRM at this room's history. Idempotent and cheap: it rewrites `code_roots.txt`
79
+ * only when the set of existing history files has actually changed, so it is fine to call
80
+ * on every join and again whenever one of those files is written.
81
+ *
82
+ * Returns { linked, files, reason }. Never throws -- a memory that cannot be wired up is
83
+ * not a reason to fail somebody's join.
84
+ */
85
+ function linkRoom(dir, log = () => {}) {
86
+ try {
87
+ const p = rootsPath()
88
+
89
+ // DO NOT CREATE THIS FILE. GRM's reader has three states, and they are not two:
90
+ // no file at all -> fall back to the default root; a file naming paths that all fail
91
+ // to resolve -> watch NOTHING, deliberately, so a typo is visible rather than buried.
92
+ // Creating one here containing only HIVE_*.md would therefore convert "GRM indexes my
93
+ // projects" into "GRM indexes this room and nothing else" the moment the paths lag
94
+ // behind the folder. Only ever append to a file GRM already owns.
95
+ if (!fs.existsSync(p)) return { linked: false, files: [], reason: 'GRM is not installed on this machine' }
96
+
97
+ const files = HISTORY_FILES
98
+ .map((f) => path.resolve(dir, f))
99
+ .filter((f) => { try { return fs.statSync(f).isFile() } catch { return false } })
100
+ if (!files.length) return { linked: false, files: [], reason: 'room has not written its history yet' }
101
+
102
+ const tag = tagFor(dir)
103
+ const { bom, body } = readRoots(p)
104
+ const stripped = stripBlock(body, tag)
105
+ const next = stripped.replace(/\s*$/, '') + '\n\n' + blockFor(dir, tag, files) + '\n'
106
+
107
+ if (bom + next === bom + body) return { linked: true, files, reason: 'already linked' } // no write, no watcher churn
108
+ writeRootsAtomic(p, bom, next)
109
+ log(`[hive] room history handed to GRM (${files.length} file(s)) -- agents here can now recall what this room decided before they joined.`)
110
+ return { linked: true, files, reason: 'linked' }
111
+ } catch (e) {
112
+ log(`[hive] could not hand this room's history to GRM: ${e && e.message}`)
113
+ return { linked: false, files: [], reason: String((e && e.message) || e) }
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Remove this room from GRM's roots. Called on leave, so a room you are no longer in stops
119
+ * contributing to what your agents recall.
120
+ */
121
+ function unlinkRoom(dir, log = () => {}) {
122
+ try {
123
+ const p = rootsPath()
124
+ if (!fs.existsSync(p)) return { unlinked: false, reason: 'GRM is not installed on this machine' }
125
+ const tag = tagFor(dir)
126
+ const { bom, body } = readRoots(p)
127
+ if (!body.includes('# >>> ' + tag)) return { unlinked: false, reason: 'was not linked' }
128
+ writeRootsAtomic(p, bom, stripBlock(body, tag).replace(/\s*$/, '') + '\n')
129
+ log('[hive] room history removed from GRM.')
130
+ return { unlinked: true, reason: 'unlinked' }
131
+ } catch (e) {
132
+ return { unlinked: false, reason: String((e && e.message) || e) }
133
+ }
134
+ }
135
+
136
+ /** Whether GRM is present at all -- used to decide whether to mention memory in the UI. */
137
+ function grmPresent() {
138
+ try { return fs.existsSync(rootsPath()) } catch { return false }
139
+ }
140
+
141
+ module.exports = { linkRoom, unlinkRoom, grmPresent, rootsPath, HISTORY_FILES }
@@ -0,0 +1,143 @@
1
+ // The room mailbox: what has this participant actually read?
2
+ //
3
+ // WHY THIS EXISTS. `hive_wait` used to capture `baseChatLen = chat.length` at the moment it
4
+ // was CALLED, and only ever returned `chat.slice(baseChatLen)`. So anything posted between one
5
+ // wait returning and the next one starting — which is precisely the window where an agent is
6
+ // doing its work — sat below the cursor and was returned by NO code path. Not delayed:
7
+ // unreachable. Two further holes finished the job: a wait that had queued work returned
8
+ // `newChat: []` hardcoded, so the busiest agent in the room saw no messages at all; and
9
+ // mention detection existed only for PAUSED agents, so a working agent could not tell "stop,
10
+ // you are editing the wrong file" from two other agents chatting.
11
+ //
12
+ // Measured on a real four-agent build (JARVIS, 2026-07-28): 345 @mentions in the room log, 13
13
+ // acknowledgements. The PM agent ended up replacing delivery with a bash script that tails
14
+ // HIVE_CHAT.md, which is the verdict on the old design.
15
+ //
16
+ // So the cursor is now a persistent per-participant position that only advances over messages
17
+ // that were actually handed to the agent. This module is deliberately pure — plain arrays in,
18
+ // plain objects out, no Yjs and no I/O — so the delivery rules are unit-testable without a
19
+ // relay, a room, or a network.
20
+
21
+ /** A chat message as stored in the room: { by, kind, at, text }. */
22
+
23
+ /**
24
+ * Does this text address `me`? Matches `@Name`, case-insensitively, at a word boundary so
25
+ * `@PM` does not swallow `@PM-Claude` and `@Hermes` does not match `@Hermes-2`.
26
+ * @param {string} text
27
+ * @param {string} me
28
+ */
29
+ export function mentionsName(text, me) {
30
+ if (!text || !me) return false
31
+ const escaped = String(me).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
32
+ // Trailing boundary: end of string, or anything that cannot continue a name.
33
+ return new RegExp(`@${escaped}(?![\\w-])`, 'i').test(String(text))
34
+ }
35
+
36
+ /** Everyone addressed in a message, so a broadcast can be told from a direct call. */
37
+ export function mentionedNames(text) {
38
+ return [...new Set([...String(text || '').matchAll(/@([\w-]+)/g)].map((m) => m[1]))]
39
+ }
40
+
41
+ /**
42
+ * How urgently does this message want attention?
43
+ *
44
+ * 'stop' — act on it before finishing what you are doing
45
+ * 'normal' — read it at your next boundary
46
+ *
47
+ * A marker is an explicit prefix so it is unambiguous, plus a small set of words that mean
48
+ * stop in every room whether or not the sender knew about markers. An agent about to
49
+ * overwrite someone's work is not going to be helped by correct syntax.
50
+ */
51
+ export function priorityOf(text) {
52
+ const s = String(text || '')
53
+ if (/^\s*\[!\]|^\s*\[stop\]/i.test(s)) return 'stop'
54
+ if (/\b(stop|halt|abort|don'?t\s+(?:edit|touch|write)|revert)\b/i.test(s)) return 'stop'
55
+ return 'normal'
56
+ }
57
+
58
+ /**
59
+ * Collapse runs of the identical message into one entry with a count.
60
+ *
61
+ * Not cosmetic. A conflict-notification feedback loop in the same live build put 56 copies of
62
+ * one "⚠ MERGE CONFLICT in Shell.jsx" line into the room in seconds, and 306 of the room's
63
+ * 2,314 chat lines were that loop. Handing an agent 56 identical lines costs it the context it
64
+ * needs to fix the actual problem, and buries the one message that mattered.
65
+ * @param {Array<{text?: string, by?: string}>} messages
66
+ */
67
+ export function collapseRepeats(messages) {
68
+ const out = []
69
+ for (const m of messages || []) {
70
+ const last = out[out.length - 1]
71
+ if (last && last.by === m.by && last.text === m.text) {
72
+ last.repeated = (last.repeated || 1) + 1
73
+ last.at = m.at ?? last.at // keep the most recent timestamp
74
+ continue
75
+ }
76
+ out.push({ ...m })
77
+ }
78
+ return out
79
+ }
80
+
81
+ /**
82
+ * A read cursor over the room chat.
83
+ *
84
+ * @param {{me: string, startAt?: number}} options
85
+ * me this participant's display name
86
+ * startAt where to begin. On a first join this is the current chat length, so an agent is
87
+ * not handed the room's entire history; on a resume it is the persisted cursor, so
88
+ * nothing posted while the process was down is lost.
89
+ */
90
+ export function createMailbox({ me, startAt = 0 }) {
91
+ let cursor = Math.max(0, Number(startAt) || 0)
92
+
93
+ /** Read without consuming. `advance` is what `take` does; kept separate so a peek is safe. */
94
+ function collect(all, { advance }) {
95
+ const list = Array.isArray(all) ? all : []
96
+ // A cursor past the end means this position belongs to a different document — a room
97
+ // rebuilt from scratch, which the recovery procedure for a poisoned room requires. Reset
98
+ // to the beginning rather than clamping to the end: clamping would deliver nothing, for
99
+ // ever, silently. Re-reading a short fresh chat costs an agent a few lines; going
100
+ // permanently deaf costs it the room.
101
+ if (cursor > list.length) cursor = 0
102
+
103
+ const fresh = list.slice(cursor)
104
+ const end = list.length
105
+
106
+ // An agent's own messages are not mail. It used to be woken by its own hive_say, and in a
107
+ // notification storm most of the flood was self-authored — an agent filling its own inbox.
108
+ const incoming = fresh.filter((m) => m && m.by !== me)
109
+ const messages = collapseRepeats(incoming)
110
+ const mentions = messages.filter((m) => mentionsName(m.text, me))
111
+ const urgent = mentions.filter((m) => priorityOf(m.text) === 'stop')
112
+
113
+ if (advance) cursor = end
114
+
115
+ return {
116
+ messages,
117
+ mentions,
118
+ mentioned: mentions.length > 0,
119
+ priority: urgent.length ? 'stop' : 'normal',
120
+ unreadCount: incoming.length,
121
+ cursor,
122
+ }
123
+ }
124
+
125
+ return {
126
+ /** Current position, for persisting across process restarts. */
127
+ get cursor() { return cursor },
128
+ /** Move the cursor without delivering anything (used at join). */
129
+ seek(n) { cursor = Math.max(0, Number(n) || 0); return cursor },
130
+ /** Deliver everything unread and mark it read. Every return path must go through this. */
131
+ take(all) { return collect(all, { advance: true }) },
132
+ /** Look at what is unread without marking it read. */
133
+ peek(all) { return collect(all, { advance: false }) },
134
+ /** Is there anything at all? Cheap enough to call in a wake predicate. */
135
+ hasUnread(all) { return collect(all, { advance: false }).unreadCount > 0 },
136
+ }
137
+ }
138
+
139
+ /** One-line rendering used in tool output, with the repeat count when a run was collapsed. */
140
+ export function formatMessage(m) {
141
+ const times = m.repeated > 1 ? ` (×${m.repeated})` : ''
142
+ return `${m.at || ''} ${m.by}(${m.kind || 'ai'}): ${m.text}${times}`
143
+ }
package/hive-mcp.js CHANGED
@@ -88,7 +88,26 @@ async function joinRoom({ link = '', dir = './workspace', name = `agent-${crypto
88
88
  // persist the token too, so a re-join from .hive.json keeps the grant (the
89
89
  // invitee pastes the link once; subsequent joins just read the folder config).
90
90
  fs.writeFileSync(path.join(ROOT, '.hive.json'), JSON.stringify({ relay, room, ...(useToken ? { token: useToken } : {}) }, null, 2))
91
- const hive = startSync({ relay, room, dir, name, kind: 'ai', owner, token: useToken, log: () => {} })
91
+ // The ACCOUNT licence is passed through but NOT persisted to .hive.json above, and that
92
+ // asymmetry is deliberate. `.hive.json` sits in the project folder, so it gets committed
93
+ // and it gets shared -- fine for a room invite, which is what it is for, and wrong for a
94
+ // billing credential. startSync resolves the licence from $HIVE_LICENSE (this server's env
95
+ // block) or ~/.hivecode/config.json instead, both of which are per-machine.
96
+ // A refused folder lock must reach the AGENT as a readable instruction, not as a stack
97
+ // trace. An agent that gets an opaque failure retries, and two of them retrying on one
98
+ // folder is precisely the situation the lock exists to prevent.
99
+ let hive
100
+ try {
101
+ hive = startSync({ relay, room, dir, name, kind: 'ai', owner, token: useToken, log: () => {} })
102
+ } catch (e) {
103
+ if (e && e.code === 'EHIVELOCKED') {
104
+ return text(
105
+ `${e.message}\n\n` +
106
+ `Do NOT retry this folder. Either join with a different \`dir\`, or stop process ${e.pid} first.`,
107
+ )
108
+ }
109
+ throw e
110
+ }
92
111
  session = { hive, room, relay, dir, name, owner, mailbox: null }
93
112
  persistSession({ dir, name, owner }) // survive MCP host restarts (see tryResumeSession)
94
113
  // wait for the first sync so members/board/rules are ready
@@ -406,7 +425,20 @@ function enforceDcoLock(opts = {}) {
406
425
  // matters most here: an 0.10.0 client in a room with an 0.11.1 one is invisible in the audit
407
426
  // log while its roommate is named, so the room's own membership record disagrees with itself.
408
427
  // Also carries the ICR fix that stopped a whole-file rewrite duplicating every declaration.
409
- export const VERSION = '0.11.1'
428
+ // VERSION is load-bearing, not cosmetic: the join message (see buildJoinReply) instructs every
429
+ // arriving agent to announce it in chat, because a MIXED BUILD in one room causes data loss and
430
+ // this is the only way anyone finds out. Hardcoding it meant that the moment package.json was
431
+ // bumped the two disagreed silently -- so the one mechanism for detecting a mixed build was
432
+ // itself reporting the wrong build. Read the truth instead; package.json always ships in the
433
+ // tarball and sits beside this file in both the repo and the published package.
434
+ // A failed read must never stop the server from starting, so it falls back rather than throwing.
435
+ export const VERSION = (() => {
436
+ try {
437
+ const v = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version
438
+ if (typeof v === 'string' && v) return v
439
+ } catch { /* fall through */ }
440
+ return '0.0.0-unknown'
441
+ })()
410
442
  const server = new Server({ name: 'hivecode', version: VERSION }, { capabilities: { tools: {} } })
411
443
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
412
444
  async function dispatch(req) {
@@ -456,7 +488,27 @@ async function dispatch(req) {
456
488
  if (!got.unreadCount && !tasks.length) return text('✓ nothing waiting — nobody is calling you and you have no approved work. Carry on.')
457
489
  const parts = []
458
490
  if (got.priority === 'stop') {
491
+ // HANDLING URGENT MAIL COUNTS AS LIVENESS.
492
+ //
493
+ // Claims evaporate after 5 minutes so a crashed agent never deadlocks the room.
494
+ // But an agent that is preempted -- told to stop and deal with something else --
495
+ // is not crashed, it is obeying. Without this refresh it loses the file it was
496
+ // halfway through editing WHILE DOING WHAT IT WAS ASKED, another agent takes the
497
+ // claim, and it resumes writing into a region it no longer owns. The agent gets
498
+ // punished for complying, and the resulting clobber looks like a merge bug.
499
+ //
500
+ // Re-claiming a region you already hold is safe: acquire() only defers when a
501
+ // DIFFERENT owner holds it, so this refreshes `at` and never steals.
502
+ //
503
+ // One refresh buys another full TTL. A long interruption stays safe because the
504
+ // rules have the agent calling hive_check between steps of the urgent work too.
505
+ try {
506
+ for (const c of s.hive.claimsBoard()) {
507
+ if (c.by === s.name) s.hive.claim(c.region, c.intent || 'edit')
508
+ }
509
+ } catch { /* delivering the urgent message matters more than the refresh */ }
459
510
  parts.push('🛑 URGENT — someone needs you to STOP what you are doing and handle this FIRST:')
511
+ parts.push('(your claims were just refreshed — the files you hold stay yours while you deal with this)')
460
512
  parts.push(got.mentions.filter((m) => priorityOf(m.text) === 'stop').map(formatMessage).join('\n'))
461
513
  } else if (got.mentioned) {
462
514
  parts.push(`📣 ${got.mentions.length} message(s) addressed to YOU — answer at your next safe stopping point:`)
package/icr.js CHANGED
@@ -546,6 +546,11 @@ function spliceUnits(baseText, baseUnits, order, textByKey, opts = {}) {
546
546
  out += baseText.slice(cursor, to)
547
547
  }
548
548
 
549
+ // A KEY REPEATED IN `order` USED TO EMIT ITS UNIT AGAIN. `order` is assembled from both
550
+ // sides' unit lists, so any key present in both can appear twice, and each appearance
551
+ // wrote the declaration out. On a whole-file rewrite that is every declaration in the
552
+ // file, which is how a merge silently doubled a source file instead of merging it.
553
+ // Emit each unit exactly once, at its first ordered position.
549
554
  const seenKeys = new Set()
550
555
  for (const k of order) {
551
556
  if (seenKeys.has(k)) continue
@@ -556,6 +561,7 @@ function spliceUnits(baseText, baseUnits, order, textByKey, opts = {}) {
556
561
  emitGap(pos, u.start)
557
562
  pos = u.end
558
563
  }
564
+ // A missing key used to concatenate the string "undefined" into the source.
559
565
  const val = textByKey.get(k)
560
566
  if (val != null) out += val
561
567
  } else {
package/lang-brace.js CHANGED
@@ -31,6 +31,10 @@ function scannerAt(src, strings) {
31
31
 
32
32
  const isWS = (c) => c === ' ' || c === '\t' || c === '\r' || c === '\n'
33
33
 
34
+ // Tokens that CONTINUE a declaration onto the next line and can never begin a new top-level
35
+ // one. Used by topUnits to avoid cutting a declaration in half at a newline.
36
+ const CONT = /^(?:__attribute__|__asm__|__asm|__declspec|noexcept|throw\s*\(|->)/
37
+
34
38
  // Extract top-level units [{start,end}] from src[from..to]. A unit is a maximal
35
39
  // declaration/statement at bracket depth 0: either a block (signature + balanced
36
40
  // {…}) or a statement ending at ';' or end-of-line.
@@ -52,7 +56,36 @@ function topUnits(src, strings, from = 0, to = src.length) {
52
56
  if (CLOSE[c]) { depth--; i++; if (depth <= 0 && sawBrace) { while (i < to && (src[i] === ' ' || src[i] === '\t')) i++; if (src[i] === ';') i++; break } continue }
53
57
  if (depth === 0) {
54
58
  if (c === ';') { i++; break }
55
- if (c === '\n' && !sawBrace) break
59
+ if (c === '\n' && !sawBrace) {
60
+ // K&R / Allman / kernel style: the body's opening brace sits on the NEXT line, so
61
+ // this newline is INSIDE a declaration, not the end of one. Breaking here split
62
+ // every such function into two units — `stmt:<signature>` and `stmt:{` — with
63
+ // POSITIONAL keys, so any insertion elsewhere shifted the indices and re-paired
64
+ // body #N with signature #N+1. That silently produced code whose function bodies
65
+ // belonged to other functions, while braces stayed balanced so the parse gate
66
+ // passed. Measured at ~34% of real C merges before this fix.
67
+ // Look ahead past whitespace and comments: if a block follows, it belongs to us.
68
+ let k = i + 1
69
+ for (;;) {
70
+ while (k < to && isWS(src[k])) k++
71
+ const s2 = at(k)
72
+ if (s2 > k && (src[k] === '/' || src[k] === '#')) { k = s2; continue } // comment
73
+ break
74
+ }
75
+ if (k < to && src[k] === '{') { i++; continue } // keep scanning; the block joins this unit
76
+ // A declaration can also continue onto the next line WITHOUT a brace, e.g.
77
+ // void addReplyErrorFormat(client *c, const char *fmt, ...)
78
+ // __attribute__((format(printf, 2, 3)));
79
+ // Splitting there produced an orphan `__attribute__…;` unit whose text is IDENTICAL
80
+ // across many prototypes in a header, so the keys became positional (#2, #3, …) and
81
+ // shifted exactly like the signature/body case — orphaning attributes onto the wrong
82
+ // prototypes. Only tokens that can never BEGIN a new top-level declaration are listed
83
+ // here: `const`/`override`/`final` are deliberately excluded because in TS/Rust they
84
+ // legitimately start the next statement (the C++ uses of those end in `{`, which the
85
+ // brace lookahead above already handles).
86
+ if (k < to && CONT.test(src.slice(k, k + 16))) { i++; continue }
87
+ break
88
+ }
56
89
  }
57
90
  i++
58
91
  }
@@ -67,15 +100,33 @@ const VARD = /\b(const|let|var|val|static|final)\s+([A-Za-z_$][\w$]*)/
67
100
  // void main(String[] a)`, `std::string name()`): optional decorators + any number of
68
101
  // modifiers + an optional return-type token, then NAME(args){. The captured name is
69
102
  // the identifier directly before `(`.
70
- const METH = /^(?:@[\w.()]+\s+)*(?:(?:public|private|protected|internal|static|async|final|override|open|pub|suspend|inline|virtual|export|default|abstract|sealed|unsafe|extern|synchronized|native|constexpr)\s+)*(?:(?:function|fn|func|fun)\s+)?(?:[A-Za-z_$][\w$<>\[\].,:&*\s]*?\s+)?([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\s*\([^;{]*\)\s*(?:->[^{]+|:[^={]+)?\s*\{/
103
+ // The return-type group ends with `[\s*&]+` rather than `\s+` so a pointer return type whose
104
+ // star is attached to the NAME still parses: `struct sk_buff *alloc_skb(int n)` must key as
105
+ // `alloc_skb`, not fall through to DECL and key as the return type `sk_buff`.
106
+ // The `Class::` group makes C++ out-of-line member definitions keyable — `int Widget::render(…)`
107
+ // keys as `render`. Without it every C++ method definition fell through to an unkeyed `stmt:`.
108
+ const METH = /^(?:@[\w.()]+\s+)*(?:(?:public|private|protected|internal|static|async|final|override|open|pub|suspend|inline|virtual|export|default|abstract|sealed|unsafe|extern|synchronized|native|constexpr)\s+)*(?:(?:function|fn|func|fun)\s+)?(?:[A-Za-z_$][\w$<>\[\].,:&*\s]*?[\s*&]+)?(?:[A-Za-z_$][\w$]*\s*::\s*)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\s*\([^;{]*\)(?:\s*(?:const|noexcept|override|final|volatile|mutable|&&?|throw\s*\([^){]*\)))*\s*(?:->[^{]+|:[^={]+)?\s*\{/
71
109
  // control-flow headers also look like `name (…) {` — never key them as methods
72
110
  const CTRL = new Set(['if', 'for', 'while', 'switch', 'catch', 'do', 'else', 'try', 'return', 'new', 'throw', 'match', 'select', 'when', 'until', 'lock', 'using', 'foreach', 'with'])
73
111
 
74
112
  function keyOf(text) {
75
113
  const head = text.replace(/^\s+/, '')
76
- let m = head.match(DECL); if (m) return m[1] + ':' + m[2]
77
- m = head.match(METH); if (m && !CTRL.has(m[1])) return 'method:' + m[1]
78
- m = head.match(VARD); if (m) return 'var:' + m[2]
114
+ // DECL and METH can both match one declaration, and which is RIGHT depends on whether they
115
+ // agree on the name:
116
+ // `function a() {` -> both say "a" -> keep DECL's richer `function:a`
117
+ // `struct sk_buff *alloc_skb(` -> DECL says "sk_buff",
118
+ // METH says "alloc_skb" -> DECL matched the RETURN TYPE,
119
+ // so METH wins and the key names the function.
120
+ // Preferring DECL unconditionally (the original order) meant every pointer-returning C
121
+ // function was keyed by its return type, feeding the rename/dangling logic wrong names.
122
+ const meth = head.match(METH)
123
+ const decl = head.match(DECL)
124
+ if (meth && !CTRL.has(meth[1])) {
125
+ if (decl && decl[2] === meth[1]) return decl[1] + ':' + decl[2]
126
+ return 'method:' + meth[1]
127
+ }
128
+ if (decl) return decl[1] + ':' + decl[2]
129
+ const m = head.match(VARD); if (m) return 'var:' + m[2]
79
130
  return 'stmt:' + head.split('\n')[0].trim().replace(/\s+/g, ' ')
80
131
  }
81
132
 
@@ -130,16 +181,30 @@ function isRefToken(src, t) {
130
181
  function balanced(src, strings) {
131
182
  const at = scannerAt(src, strings)
132
183
  const stack = []
184
+ let cpp = 0 // #if/#ifdef/#ifndef nesting depth
185
+ let lineStart = true // only whitespace seen so far on this line
133
186
  let i = 0
134
187
  while (i < src.length) {
135
188
  const s = at(i)
136
- if (s > i) { if (s > src.length) return false; i = s; continue }
189
+ if (s > i) { if (s > src.length) return false; i = s; lineStart = false; continue }
137
190
  const c = src[i]
138
191
  if (OPEN[c]) stack.push(c)
139
192
  else if (CLOSE[c]) { if (stack.pop() !== CLOSE[c]) return false }
193
+ // PREPROCESSOR NESTING. Brace balance alone cannot see a dropped `#endif`: the merged
194
+ // text stays perfectly brace-balanced while the conditional structure is broken, so the
195
+ // gate passed and invalid C shipped as clean. Directives are matched only at the start
196
+ // of a line, so `#[derive]`, `#region` and `#` inside strings/comments are unaffected
197
+ // (strings and comments are skipped by the scanner above).
198
+ else if (c === '#' && lineStart) {
199
+ const rest = src.slice(i + 1, i + 12).replace(/^\s+/, '')
200
+ if (/^(ifdef|ifndef|if)\b/.test(rest)) cpp++
201
+ else if (/^endif\b/.test(rest)) { cpp--; if (cpp < 0) return false }
202
+ }
203
+ if (c === '\n') lineStart = true
204
+ else if (c !== ' ' && c !== '\t' && c !== '\r') lineStart = false
140
205
  i++
141
206
  }
142
- return stack.length === 0
207
+ return stack.length === 0 && cpp === 0
143
208
  }
144
209
 
145
210
  export function makeBraceLanguage({ id, exts, strings = ['"', "'", '`'] }) {