klypix-mcp 1.55.0 → 1.56.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/A2A.md CHANGED
@@ -26,10 +26,17 @@ Flags / env: `--vault` (`KLYPIX_VAULT`), `--port` (`KLYPIX_A2A_PORT`, default
26
26
  `41241`), `--host` (`KLYPIX_A2A_HOST`, loopback only), and
27
27
  `--allow-cross-project` (opt in to machine-wide registered-brain search).
28
28
 
29
- It is **local-only**: it binds loopback and needs no auth because callers are on
30
- the same machine. Non-loopback `--host` values are refused. Remote exposure is
31
- unsupported until authentication, TLS, a real identity model, and cross-process
32
- write coordination exist together.
29
+ It is **local-only and OS-user-authenticated**: it binds loopback (non-loopback
30
+ `--host` values are refused), and because loopback is *machine*-local — not
31
+ user-local — every mutating `POST /` requires a bearer token. The server writes
32
+ a fresh token per start to `~/.claude/project-brain/.a2a-token-<port>` (the same
33
+ user-ACL boundary that protects the coordination lane), so only a process
34
+ running as your OS user can read it. Clients: `GET /health` first and check
35
+ `auth.tokenFingerprint` equals `sha256(token)[:16]` from your token file —
36
+ verifying the server before sending the token, so a port-squatting impostor can
37
+ neither pass verification nor harvest it. `KLYPIX_A2A_TOKEN` sets a shared
38
+ secret instead; `--no-auth` opts out explicitly. Remote exposure remains
39
+ unsupported until TLS and a real identity model exist together.
33
40
 
34
41
  ## Discover it
35
42
 
package/README.md CHANGED
@@ -550,7 +550,9 @@ keep lazy first-use indexing instead.
550
550
  - **The optional semantic model runs on device.** Enabling it (or upgrading its model) can fetch
551
551
  model weights from Hugging Face; retrieval inference and brain data stay local.
552
552
  - **Coordination state is local files.** The brain is a file in your repo; the presence lane is a
553
- file under your home directory. Nothing is uploaded.
553
+ file under your home directory. Nothing is uploaded — with one explicit, default-OFF exception:
554
+ the cross-PC presence relay, which (only after per-brain consent in the KLYPIX desktop app)
555
+ shares metadata-only presence frames over that brain's cloud channel. No consent, no frames.
554
556
  - **`install` writes to your home directory:** `~/.claude/project-brain` (engine + runtime),
555
557
  `~/.claude/settings.json` (four hooks — written even if Claude Code is not installed),
556
558
  `~/.codex/AGENTS.md` (guidance block), and with `--codex-hooks`, `~/.codex/hooks.json`. It also
@@ -26,6 +26,7 @@
26
26
 
27
27
  import http from 'http';
28
28
  import fs from 'fs';
29
+ import os from 'os';
29
30
  import path from 'path';
30
31
  import crypto from 'crypto';
31
32
  import { fileURLToPath } from 'url';
@@ -63,6 +64,44 @@ if (!isLoopbackHostname(HOST)) {
63
64
  process.exit(2);
64
65
  }
65
66
 
67
+ // ── OS-user auth boundary ────────────────────────────────────────────────────
68
+ // Loopback TCP is MACHINE-local, not OS-user-local: on a shared machine any
69
+ // other local user (or sandboxed process) can reach this port — and POST /
70
+ // includes brain WRITES, which land in every future session's context. A
71
+ // per-start bearer token stored under ~/.claude/project-brain (the SAME
72
+ // user-ACL boundary that already protects the coordination lane) restores the
73
+ // stated boundary: only a process that can read this user's home can mutate.
74
+ // KLYPIX_A2A_TOKEN overrides for shared-secret setups; --no-auth opts out
75
+ // explicitly (never silently).
76
+ const NO_AUTH = process.argv.includes('--no-auth') || process.env.KLYPIX_A2A_NO_AUTH === '1';
77
+ const TOKEN_FILE = path.join(os.homedir(), '.claude', 'project-brain', `.a2a-token-${PORT}`);
78
+ const TOKEN = (() => {
79
+ if (NO_AUTH) return null;
80
+ const fromEnv = String(process.env.KLYPIX_A2A_TOKEN || '').trim();
81
+ if (fromEnv) return fromEnv;
82
+ const token = crypto.randomBytes(32).toString('hex');
83
+ try {
84
+ fs.mkdirSync(path.dirname(TOKEN_FILE), { recursive: true });
85
+ fs.writeFileSync(TOKEN_FILE, token, { mode: 0o600 });
86
+ } catch (e) {
87
+ console.error(`[klypix-a2a] Could not write the auth token file (${e?.message || e}); refusing to start UNAUTHENTICATED. Set KLYPIX_A2A_TOKEN or pass --no-auth explicitly.`);
88
+ process.exit(2);
89
+ }
90
+ return token;
91
+ })();
92
+ // Fingerprint (never the token) served on /health: a client verifies the server
93
+ // actually holds the token from THIS user's token file BEFORE sending it — so a
94
+ // port-squatting impostor can neither pass verification nor harvest the token.
95
+ const TOKEN_FINGERPRINT = TOKEN ? crypto.createHash('sha256').update(TOKEN).digest('hex').slice(0, 16) : null;
96
+ function authorized(req) {
97
+ if (!TOKEN) return true;
98
+ const m = /^Bearer\s+(.+)$/i.exec(String(req.headers.authorization || '').trim());
99
+ if (!m) return false;
100
+ const presented = Buffer.from(m[1].trim());
101
+ const expected = Buffer.from(TOKEN);
102
+ return presented.length === expected.length && crypto.timingSafeEqual(presented, expected);
103
+ }
104
+
66
105
  const KLYPIX_MIME = 'application/vnd.klypix+zip';
67
106
  const now = () => new Date().toISOString();
68
107
  const uuid = () => crypto.randomUUID();
@@ -615,10 +654,18 @@ const server = http.createServer((req, res) => {
615
654
  return sendJson(res, 200, agentCard(publicUrl));
616
655
  }
617
656
  if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/health')) {
618
- return sendJson(res, 200, { name: 'klypix-a2a', version: PKG.version, agentCard: `${publicUrl}.well-known/agent-card.json` });
657
+ return sendJson(res, 200, {
658
+ name: 'klypix-a2a', version: PKG.version, agentCard: `${publicUrl}.well-known/agent-card.json`,
659
+ auth: TOKEN
660
+ ? { scheme: 'bearer', tokenFile: TOKEN_FILE, tokenFingerprint: TOKEN_FINGERPRINT }
661
+ : { scheme: 'none' },
662
+ });
619
663
  }
620
664
 
621
665
  if (req.method === 'POST') {
666
+ if (!authorized(req)) {
667
+ return sendJson(res, 401, rpcErr(null, -32001, `Unauthorized. Read the bearer token (same OS user) from ${TOKEN_FILE} and send "Authorization: Bearer <token>"; verify the server first via GET /health tokenFingerprint.`));
668
+ }
622
669
  const contentType = String(req.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase();
623
670
  if (contentType !== 'application/json') {
624
671
  return sendJson(res, 415, rpcErr(null, -32600, 'Content-Type must be application/json.'));
@@ -657,6 +704,7 @@ server.maxConnections = 16;
657
704
 
658
705
  server.listen(PORT, HOST, () => {
659
706
  log(`ready · vault=${VAULT}`);
707
+ log(TOKEN ? `auth: bearer (token file ${TOKEN_FILE} · fingerprint ${TOKEN_FINGERPRINT})` : 'auth: NONE (--no-auth) — any local process, including other OS users, can write');
660
708
  log(`agent card: http://${HOST}:${PORT}/.well-known/agent-card.json`);
661
709
  log(`A2A endpoint (JSON-RPC): http://${HOST}:${PORT}/`);
662
710
  // Bounded mode is lazy; legacy mode is the exact eager-prewarm rollback path.
@@ -19,6 +19,7 @@
19
19
 
20
20
  import fs from 'fs';
21
21
  import { appendToKlypix, atomicWrite } from '../src/klypix-format.mjs';
22
+ import { brainCaptureLockPath, withAdvisoryWriteLock } from '../src/brain-write-lock.mjs';
22
23
 
23
24
  const args = process.argv.slice(2);
24
25
  const file = args.find(a => !a.startsWith('--'));
@@ -32,12 +33,21 @@ try {
32
33
  addition = JSON.parse(raw);
33
34
  } catch (e) { console.error('Addition is not valid JSON:', e.message); process.exit(2); }
34
35
 
35
- let buf;
36
- try {
37
- buf = await appendToKlypix(fs.readFileSync(file), addition);
38
- } catch (e) { console.error(e.message); process.exit(1); }
39
-
40
- await atomicWrite(file, buf);
36
+ // Read-modify-write under the SAME cross-process lock as the MCP engine, hooks,
37
+ // and desktop app; racing them unlocked is silent last-writer-wins loss.
38
+ const wrote = await withAdvisoryWriteLock(brainCaptureLockPath(file), async (locked) => {
39
+ if (!locked) return false;
40
+ let buf;
41
+ try {
42
+ buf = await appendToKlypix(fs.readFileSync(file), addition);
43
+ } catch (e) { console.error(e.message); process.exit(1); }
44
+ await atomicWrite(file, buf);
45
+ return true;
46
+ }, { tries: 100, waitMs: 60 });
47
+ if (!wrote) {
48
+ console.error('append-klypix refused (file unchanged): the write lock is held by another writer — retry in a moment.');
49
+ process.exit(1);
50
+ }
41
51
  const cardCount = Array.isArray(addition.cards) ? addition.cards.length : 0;
42
52
  const connCount = Array.isArray(addition.connections) ? addition.connections.length : 0;
43
53
  console.log(`Appended ${cardCount} card(s), ${connCount} connection(s) to ${file}.`);
@@ -173,6 +173,11 @@ if (process.argv[2] === 'init') {
173
173
 
174
174
  const vaultArgIdx = process.argv.indexOf('--vault');
175
175
  const VAULT = resolveVault(vaultArgIdx >= 0 ? process.argv[vaultArgIdx + 1] : undefined);
176
+ // A silent ~/Documents fallback is how idle default-root pairs hide inside the
177
+ // machine's RAM total. Say it loudly; brain_sync {project} re-routes per call.
178
+ if (vaultArgIdx < 0 && !process.env.KLYPIX_VAULT) {
179
+ log(`DEFAULT ROOT: no --vault/KLYPIX_VAULT — vault fell back to ${VAULT}. Pass the project root via brain_sync {project} (or configure --vault) so this connection serves a real project.`);
180
+ }
176
181
  const server = new McpServer(
177
182
  { name: 'klypix-canvas', version: PKG_VERSION },
178
183
  {
@@ -263,11 +268,27 @@ server.registerTool('project_map_context', {
263
268
  deep_history: z.boolean().optional().describe('false (default) uses the sub-second lexical-fast correction-aware path; true opts into whole-brain semantic/history retrieval, which may cold-load the local model.'),
264
269
  },
265
270
  }, async ({ question, project, graph_path, compare_to, depth, max_nodes, k, deep_history }) => {
271
+ // One root for BOTH the graph and the brain: mixing repo-X code evidence with
272
+ // repo-Y decisions under a combined banner is silently misleading. When the
273
+ // caller explicitly targets another project, that project's OWN brain answers
274
+ // (pinned via `canvas`, which beats the KLYPIX_BRAIN env override) — and when
275
+ // it has no brain, the output says so instead of borrowing the session brain.
276
+ const explicitProject = typeof project === 'string' && project.trim() ? path.resolve(project.trim()) : null;
277
+ const sessionRoot = path.resolve(mcpPresence.vault);
278
+ const foreignProject = explicitProject && path.relative(sessionRoot, explicitProject) !== '';
279
+ const contextRoot = explicitProject || sessionRoot;
280
+ let brainCanvas;
281
+ if (foreignProject) {
282
+ const candidate = ['brain.klypix', 'brain.any']
283
+ .map(name => path.join(explicitProject, name))
284
+ .find(file => fs.existsSync(file));
285
+ brainCanvas = candidate || null;
286
+ }
266
287
  let graphResult;
267
288
  let graphMarkdown;
268
289
  try {
269
290
  graphResult = queryProjectGraph({
270
- project: project || mcpPresence.vault,
291
+ project: contextRoot,
271
292
  graphPath: graph_path,
272
293
  query: question,
273
294
  depth,
@@ -275,7 +296,7 @@ server.registerTool('project_map_context', {
275
296
  });
276
297
  if (compare_to) {
277
298
  const previousGraphResult = queryProjectGraph({
278
- project: project || mcpPresence.vault,
299
+ project: contextRoot,
279
300
  graphPath: compare_to,
280
301
  query: question,
281
302
  depth,
@@ -291,16 +312,23 @@ server.registerTool('project_map_context', {
291
312
  const graphFiles = Array.isArray(graphResult?.nodes)
292
313
  ? [...new Set(graphResult.nodes.map(node => node.sourceFile).filter(Boolean))].slice(0, 20)
293
314
  : [];
294
- const brainResult = deep_history
295
- ? await opBrainAsk({
296
- vault: mcpPresence.vault,
297
- question,
298
- k: Math.max(1, Math.min(20, Number(k) || 8)),
299
- log,
300
- })
301
- : await opBrainTaskContext({
302
- vault: mcpPresence.vault,
303
- intent: question,
315
+ const brainResult = foreignProject && brainCanvas === null
316
+ ? {
317
+ blocks: [{ kind: 'text', text: `No brain.klypix was found in ${contextRoot} — code evidence only. The session brain was deliberately NOT substituted, so decisions from another project can never masquerade as this one's.` }],
318
+ context: { mode: 'lexical-fast', hits: [], sufficient: false },
319
+ }
320
+ : deep_history
321
+ ? await opBrainAsk({
322
+ vault: contextRoot,
323
+ canvas: brainCanvas,
324
+ question,
325
+ k: Math.max(1, Math.min(20, Number(k) || 8)),
326
+ log,
327
+ })
328
+ : await opBrainTaskContext({
329
+ vault: contextRoot,
330
+ canvas: brainCanvas,
331
+ intent: question,
304
332
  files: graphFiles,
305
333
  k: Math.max(1, Math.min(8, Number(k) || 8)),
306
334
  budgetChars: 4_500,
@@ -711,10 +739,27 @@ const stopRuntimePresence = () => {
711
739
  recordRunningServer({ remove: true });
712
740
  mcpPresence.stop();
713
741
  };
742
+ // Supervisor watchdog: reaping is otherwise 100% stdin-EOF-dependent, so a
743
+ // supervisor that dies without closing pipes pinned this worker (and its RAM)
744
+ // forever. KLYPIX_MCP_SUPERVISOR_PID was set at spawn and read nowhere — the
745
+ // heartbeat now polls it. EPERM = alive without permission; ESRCH = gone.
746
+ const SUPERVISOR_PID = Number(process.env.KLYPIX_MCP_SUPERVISOR_PID || 0) || null;
747
+ const supervisorAlive = () => {
748
+ if (!SUPERVISOR_PID) return true;
749
+ try { process.kill(SUPERVISOR_PID, 0); return true; }
750
+ catch (error) { return error?.code === 'EPERM'; }
751
+ };
714
752
  server.server.oninitialized = () => {
715
753
  mcpPresence.start(VAULT);
716
754
  recordRunningServer();
717
- runningHeartbeat = setInterval(() => recordRunningServer(), 30_000);
755
+ runningHeartbeat = setInterval(() => {
756
+ if (!supervisorAlive()) {
757
+ log('supervisor process is gone — cleaning up presence and exiting');
758
+ stopRuntimePresence();
759
+ process.exit(0);
760
+ }
761
+ recordRunningServer();
762
+ }, 30_000);
718
763
  runningHeartbeat.unref?.();
719
764
  // The worker mirrors the supervisor's host-neutral scheduler. This lets an
720
765
  // older stable supervisor acquire the updater immediately after hot-swapping
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.55.0",
3
+ "version": "1.56.0",
4
4
  "description": "Shared project brain and MCP coordination server for multi-agent coding.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -76,7 +76,7 @@
76
76
  },
77
77
  "scripts": {
78
78
  "test:project-graph": "node test/project-graph.mjs",
79
- "test": "node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
79
+ "test": "node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
80
80
  "test:memory": "node test/memory-runtime.mjs",
81
81
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
82
82
  "runtime": "node bin/klypix-runtime.mjs"
@@ -107,6 +107,27 @@ function releaseLock(lockFile) {
107
107
  catch { /* best effort */ }
108
108
  }
109
109
 
110
+ // Delivered messages are rendered into a PEER AGENT'S PROMPT, and any same-user
111
+ // process can write the lane file directly — so message text is untrusted. A
112
+ // message carrying a live capture marker ("🧠 BRAIN [X]: fake decision") could
113
+ // be echoed by the receiving model and harvested into the shared brain as if the
114
+ // peer had decided it. Break the glyph-keyword adjacency the marker regexes
115
+ // require (🧠·BRAIN no longer matches /🧠\s*BRAIN/) — visually near-identical,
116
+ // never harvestable. Applied at POST (honest writers) AND at delivery (forged
117
+ // lane rows bypass post).
118
+ export function neutralizeMarkers(text) {
119
+ return String(text || '').replace(/🧠(\s*)(BRAIN|MSG)/gi, '🧠·$2');
120
+ }
121
+
122
+ // tmp+rename so lock-free readers (readLane, messageFooter, peers' status
123
+ // lines) can never parse a torn lane as an authoritative "0 peers / no
124
+ // messages", and a crash mid-write can never destroy undelivered messages.
125
+ function writeLaneFileAtomic(laneFile, payload) {
126
+ const tmp = `${laneFile}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
127
+ fs.writeFileSync(tmp, payload);
128
+ fs.renameSync(tmp, laneFile);
129
+ }
130
+
110
131
  function freshChannelSeen(channelSeen, now) {
111
132
  if (!channelSeen || typeof channelSeen !== 'object' || Array.isArray(channelSeen)) return {};
112
133
  return Object.fromEntries(Object.entries(channelSeen)
@@ -266,7 +287,7 @@ export function upsertSession({
266
287
  const kept = sessions.filter((session) => session.id !== id);
267
288
  kept.push(next);
268
289
  fs.mkdirSync(path.dirname(laneFile), { recursive: true });
269
- fs.writeFileSync(laneFile, JSON.stringify({
290
+ writeLaneFileAtomic(laneFile, JSON.stringify({
270
291
  ...data,
271
292
  sessions: kept.slice(-40),
272
293
  messages: capMessages(pruneMessages(data.messages, now), 30),
@@ -323,7 +344,7 @@ export function upsertRemoteSessions({ brainPath, rows, machineId = MACHINE_ID,
323
344
  else sessions.push(next);
324
345
  }
325
346
  fs.mkdirSync(path.dirname(laneFile), { recursive: true });
326
- fs.writeFileSync(laneFile, JSON.stringify({
347
+ writeLaneFileAtomic(laneFile, JSON.stringify({
327
348
  ...data,
328
349
  sessions: sessions.slice(-40),
329
350
  messages: capMessages(pruneMessages(data.messages, now), 30),
@@ -347,7 +368,7 @@ export function purgeRemoteSessions({ brainPath, home, now = Date.now() }) {
347
368
  const data = readLane(laneFile);
348
369
  const sessions = pruneSessions(data.sessions, now).filter((session) => session.via !== 'cloud');
349
370
  fs.mkdirSync(path.dirname(laneFile), { recursive: true });
350
- fs.writeFileSync(laneFile, JSON.stringify({
371
+ writeLaneFileAtomic(laneFile, JSON.stringify({
351
372
  ...data,
352
373
  sessions,
353
374
  messages: capMessages(pruneMessages(data.messages, now), 30),
@@ -385,7 +406,7 @@ export function removeSession({ brainPath, id, channel = null, home, now = Date.
385
406
  });
386
407
  }
387
408
  fs.mkdirSync(path.dirname(laneFile), { recursive: true });
388
- fs.writeFileSync(laneFile, JSON.stringify({
409
+ writeLaneFileAtomic(laneFile, JSON.stringify({
389
410
  ...data,
390
411
  sessions,
391
412
  messages: capMessages(pruneMessages(data.messages, now), 30),
@@ -469,7 +490,7 @@ export function receiveMessages({
469
490
  if (!Array.isArray(message.seen)) message.seen = [];
470
491
  if (!message.seen.includes(sessionId)) message.seen.push(sessionId);
471
492
  }
472
- fs.writeFileSync(laneFile, JSON.stringify({ ...data, sessions, messages }));
493
+ writeLaneFileAtomic(laneFile, JSON.stringify({ ...data, sessions, messages }));
473
494
  }
474
495
  return delivered;
475
496
  } finally {
@@ -522,7 +543,7 @@ export function postPresenceMessage({
522
543
  home,
523
544
  now = Date.now(),
524
545
  }) {
525
- const body = String(text || '').replace(/\s+/g, ' ').trim().slice(0, 400);
546
+ const body = neutralizeMarkers(String(text || '').replace(/\s+/g, ' ').trim().slice(0, 400));
526
547
  if (!brainPath || !from || !body) return { posted: false, message: null };
527
548
  const laneFile = laneFileFor(brainPath, home);
528
549
  const lockFile = laneFile + '.lock';
@@ -558,7 +579,7 @@ export function postPresenceMessage({
558
579
  };
559
580
  messages.push(message);
560
581
  fs.mkdirSync(path.dirname(laneFile), { recursive: true });
561
- fs.writeFileSync(laneFile, JSON.stringify({
582
+ writeLaneFileAtomic(laneFile, JSON.stringify({
562
583
  ...data,
563
584
  sessions,
564
585
  messages: capMessages(messages, 30),
@@ -670,7 +691,7 @@ export function formatReceivedMessages(messages, now = Date.now(), decay = {}) {
670
691
  const lines = ['KLYPIX message(s) from another active session:'];
671
692
  for (const message of messages) {
672
693
  const ageMin = Math.max(0, Math.round((now - Number(message.ts || now)) / 60_000));
673
- lines.push(`- from ${String(message.from || '?').slice(0, 12)} (${ageMin}m ago): ${String(message.text || '').replace(/\s+/g, ' ').trim().slice(0, 400)}`);
694
+ lines.push(`- from ${String(message.from || '?').slice(0, 12)} (${ageMin}m ago): ${neutralizeMarkers(String(message.text || '').replace(/\s+/g, ' ').trim().slice(0, 400))}`);
674
695
  const info = messageDecayInfo(message, now, decay);
675
696
  if (info) lines.push(` ${info.stampText}`);
676
697
  }
@@ -31,26 +31,11 @@ import { execSync } from 'child_process';
31
31
  const CWD = process.argv[2] || process.cwd();
32
32
  const BRAIN = path.resolve(CWD, 'brain.klypix');
33
33
  const STATE = path.resolve(CWD, '.claude', 'brain-last-commit-git');
34
- const LOCK = path.resolve(CWD, '.claude', 'brain-capture.lock'); // SAME lock the Claude-Code Stop hook uses
35
-
36
- // Advisory lockfile (mirrors global-brain-hook.mjs): O_EXCL create wins; a held
37
- // lock is waited on (sync sleep, no busy-spin); a STALE lock is stolen so a
38
- // crashed writer can't wedge the brain. Best-effort — write anyway past budget.
39
- const LOCK_STALE_MS = 15000;
40
- const sleepSync = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* */ } };
41
- function acquireLock(lockPath, { tries = 60, waitMs = 60 } = {}) {
42
- try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch { /* */ }
43
- for (let i = 0; i < tries; i++) {
44
- try { const fd = fs.openSync(lockPath, 'wx'); fs.writeSync(fd, String(process.pid)); fs.closeSync(fd); return true; }
45
- catch (e) {
46
- if (e && e.code !== 'EEXIST') return false;
47
- try { if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS) { fs.unlinkSync(lockPath); continue; } } catch { /* lost a race on the stale file — retry */ }
48
- sleepSync(waitMs);
49
- }
50
- }
51
- return false;
52
- }
53
- function releaseLock(lockPath) { try { fs.unlinkSync(lockPath); } catch { /* */ } }
34
+ // The SAME cross-process lock the Stop hook, MCP engine, and desktop app use —
35
+ // imported from brain-write-lock.mjs (heartbeat + token-checked release), so
36
+ // there is exactly ONE lock implementation. On timeout this hook REFUSES to
37
+ // write and leaves its commit baseline untouched, so the same commits re-scan
38
+ // on the next commit instead of clobbering a peer's just-merged cards.
54
39
 
55
40
  const git = (a) => execSync(`git ${a}`, { cwd: CWD, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 }).trim();
56
41
  const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
@@ -94,12 +79,24 @@ async function main() {
94
79
  if (!candidates.length) { writePrev(head); return; }
95
80
 
96
81
  const lib = await import('./klypix-format.mjs'); // lazy: only when there's something to write
82
+ let lockLib = null;
83
+ try { lockLib = await import('./brain-write-lock.mjs'); } catch { /* stale bundle without the module */ }
84
+ if (!lockLib || typeof lockLib.withAdvisoryWriteLock !== 'function') {
85
+ // Fail SAFE: without the shared lock we must not read-modify-write at
86
+ // all. The baseline stays, so these commits re-scan next commit, and the
87
+ // Stop hook (which dedups by #commit-hash) records them meanwhile.
88
+ try { process.stderr.write('[klypix-brain] git capture skipped: shared lock module unavailable (stale bundle) — commits re-scan on the next commit\n'); } catch { /* */ }
89
+ return;
90
+ }
97
91
  // Read-modify-write UNDER the shared lock so a concurrent Claude-Code Stop
98
- // hook can't clobber this batch (or vice-versa). Inside the lock, dedup
99
- // against commits ALREADY in the brain so neither a re-run nor the Stop hook
100
- // double-records the same commit.
101
- const gotLock = acquireLock(LOCK);
102
- try {
92
+ // hook or desktop save can't clobber this batch (or vice-versa). Inside the
93
+ // lock, dedup against commits ALREADY in the brain so neither a re-run nor
94
+ // the Stop hook double-records the same commit.
95
+ await lockLib.withAdvisoryWriteLock(lockLib.brainCaptureLockPath(BRAIN), async (locked) => {
96
+ if (!locked) {
97
+ try { process.stderr.write('[klypix-brain] git capture deferred: the brain lock is held — commits re-scan on the next commit\n'); } catch { /* */ }
98
+ return;
99
+ }
103
100
  const buf = fs.readFileSync(BRAIN);
104
101
  const already = new Set();
105
102
  try {
@@ -116,8 +113,6 @@ async function main() {
116
113
  await lib.atomicWrite(BRAIN, out);
117
114
  writePrev(head);
118
115
  try { process.stderr.write(`[klypix-brain] git capture: ${res.stats?.added ?? cards.length} commit card(s) → brain.klypix\n`); } catch { /* */ }
119
- } finally {
120
- if (gotLock) releaseLock(LOCK);
121
- }
116
+ }, { tries: 100, waitMs: 60 });
122
117
  }
123
118
  main().catch(() => { /* never break a commit */ }).finally(() => process.exit(0));
@@ -20,6 +20,7 @@
20
20
  import fs from 'fs';
21
21
  import path from 'path';
22
22
  import { captureIntoBrain, tidyBrain, atomicWrite, noteToCaptureInput, formatCaptureReceipts } from './klypix-format.mjs';
23
+ import { brainCaptureLockPath, withAdvisoryWriteLock } from './brain-write-lock.mjs';
23
24
 
24
25
  const MARKERS = { '': '', '?': '?', '!': '!', '✓': '✓', '~': '~', question: '?', milestone: '!', resolve: '✓', update: '~', decision: '', done: '✓' };
25
26
  const normMarker = (m) => MARKERS[String(m || '').toLowerCase()] ?? '';
@@ -58,10 +59,21 @@ if (!fs.existsSync(file)) { console.error(`brain-note: no brain at ${file} (run
58
59
 
59
60
  const input = noteToCaptureInput({ text: opts.text, area: opts.area, marker: opts.marker, closes: opts.closes, createdVia: 'cli' });
60
61
  try {
61
- const res = await captureIntoBrain(fs.readFileSync(file), input);
62
- let out = res.buffer;
63
- try { out = (await tidyBrain(res.buffer)).buffer; } catch { /* keep append result if tidy fails */ }
64
- await atomicWrite(file, out);
62
+ // Same cross-process lock as the MCP engine, hooks, and desktop app: an
63
+ // unlocked read-modify-write racing any of them is silent last-writer-wins
64
+ // loss. On timeout we REFUSE (exit 1) — the caller just reruns the command.
65
+ const res = await withAdvisoryWriteLock(brainCaptureLockPath(file), async (locked) => {
66
+ if (!locked) return null;
67
+ const captured = await captureIntoBrain(fs.readFileSync(file), input);
68
+ let out = captured.buffer;
69
+ try { out = (await tidyBrain(captured.buffer)).buffer; } catch { /* keep append result if tidy fails */ }
70
+ await atomicWrite(file, out);
71
+ return captured;
72
+ }, { tries: 100, waitMs: 60 });
73
+ if (!res) {
74
+ console.error('brain-note refused (brain unchanged): the brain lock is held by another writer — retry in a moment.');
75
+ process.exit(1);
76
+ }
65
77
  const s = res.stats || {};
66
78
  const bits = [`${s.added || 0} added`];
67
79
  for (const k of ['resolved', 'updated', 'closed', 'superseded', 'linked']) if (s[k]) bits.push(`${s[k]} ${k}`);
@@ -28,8 +28,22 @@ async function acquire(lockPath, { tries, waitMs, staleMs }) {
28
28
  } catch (error) {
29
29
  if (error?.code !== 'EEXIST') return null;
30
30
  try {
31
+ // Stale steal via rename, not stat→unlink: rename atomically claims the
32
+ // REMOVAL (a second stealer gets ENOENT and just retries), and the token
33
+ // check below detects the losing race — a FRESH successor lock that
34
+ // slipped in between our stat and our rename is put back, never deleted.
35
+ const observed = fs.readFileSync(lockPath, 'utf8');
31
36
  if (Date.now() - fs.statSync(lockPath).mtimeMs > staleMs) {
32
- fs.unlinkSync(lockPath);
37
+ const graveyard = `${lockPath}.stale-${process.pid}-${Math.random().toString(36).slice(2)}`;
38
+ fs.renameSync(lockPath, graveyard);
39
+ let stolen = null;
40
+ try { stolen = fs.readFileSync(graveyard, 'utf8'); } catch { /* already gone */ }
41
+ if (stolen !== null && stolen !== observed) {
42
+ // We displaced a fresh owner. Restore its token if the slot is still
43
+ // free ('wx' never overwrites); if not, its own token checks refuse.
44
+ try { fs.writeFileSync(lockPath, stolen, { flag: 'wx' }); } catch { /* slot re-taken */ }
45
+ }
46
+ try { fs.unlinkSync(graveyard); } catch { /* our unique file; best-effort */ }
33
47
  continue;
34
48
  }
35
49
  } catch { /* another process changed the lock; retry normally */ }
@@ -307,6 +307,30 @@ const SESSIONS_DIR = path.join(os.homedir(), '.claude', 'project-brain', 'sessio
307
307
  const laneCanon = (p) => { try { return fs.realpathSync.native(p); } catch { return path.resolve(p); } };
308
308
  const SESSIONS_FILE = path.join(SESSIONS_DIR, `${sha(normBrainPath(laneCanon(BRAIN)))}.json`); // one lane-file per PROJECT (shared by its concurrent sessions)
309
309
  const SESSIONS_LOCK = SESSIONS_FILE + '.lock';
310
+ // tmp+rename: lock-free readers must never parse a torn lane as an empty one.
311
+ function writeLaneAtomic(payload) { const tmp = SESSIONS_FILE + '.tmp-' + process.pid; fs.writeFileSync(tmp, payload); fs.renameSync(tmp, SESSIONS_FILE); }
312
+ // Pending-captures queue: when the BRAIN lock cannot be acquired, the batch is
313
+ // queued here durably instead of written from a stale base (a best-effort write
314
+ // could erase a peer's or the desktop app's just-merged cards — the exact lost
315
+ // update this lock exists to prevent). Drained under the lock by the NEXT
316
+ // capture in this project, from any session. Ids let a re-refused capture
317
+ // replace only what it drained, so a batch a peer queued meanwhile survives.
318
+ const PENDING_CAPTURES_FILE = path.join(os.homedir(), '.claude', 'project-brain', 'pending', `${sha(normBrainPath(laneCanon(BRAIN)))}.captures.json`);
319
+ const PENDING_CAPTURES_LOCK = PENDING_CAPTURES_FILE + '.lock';
320
+ function readPendingCaptures() {
321
+ try { const d = JSON.parse(fs.readFileSync(PENDING_CAPTURES_FILE, 'utf8')); return Array.isArray(d) ? d : []; } catch { return []; }
322
+ }
323
+ function updatePendingCaptures(mutate) {
324
+ const got = acquireLock(PENDING_CAPTURES_LOCK, { tries: 20, waitMs: 25 });
325
+ try {
326
+ const next = mutate(readPendingCaptures());
327
+ fs.mkdirSync(path.dirname(PENDING_CAPTURES_FILE), { recursive: true });
328
+ const tmp = PENDING_CAPTURES_FILE + '.tmp';
329
+ fs.writeFileSync(tmp, JSON.stringify(next));
330
+ fs.renameSync(tmp, PENDING_CAPTURES_FILE);
331
+ } catch { /* never break the session; the transcript markers remain the fallback */ }
332
+ finally { if (got) releaseLock(PENDING_CAPTURES_LOCK); }
333
+ }
310
334
  const SESSION_FRESH_MS = 10 * 60 * 1000; // a lane unseen for 10min is treated as ended
311
335
  const MCP_SESSION_FRESH_MS = 3 * 60 * 1000; // an mcp-channel heartbeat is dead after 3min (matches agent-presence)
312
336
  // The host CLI's pid (Claude Code exports CLAUDE_PID to every child, including
@@ -438,7 +462,7 @@ function touchSession(sid, patch = {}) {
438
462
  // MCP-written rows on every heartbeat). Message eviction prefers
439
463
  // delivered notes (capMsgs) so an unseen note is never silently lost.
440
464
  const keptMsgs = capMsgs((Array.isArray(data0.messages) ? data0.messages : []).filter(m => m && (now - (m.ts || 0) < MSG_FRESH_MS)));
441
- fs.writeFileSync(SESSIONS_FILE, JSON.stringify({ ...data0, sessions: list.slice(-40), messages: keptMsgs }));
465
+ writeLaneAtomic(JSON.stringify({ ...data0, sessions: list.slice(-40), messages: keptMsgs }));
442
466
  // Hostmap: host-pid → CURRENT session id, re-read by the MCP server on
443
467
  // every touch so its lane row follows /clear + resume id rotation. The
444
468
  // file is per-PROJECT (all host pids write it), so its read-modify-write
@@ -456,7 +480,7 @@ function touchSession(sid, patch = {}) {
456
480
  fs.renameSync(tmp, HOSTMAP_FILE);
457
481
  } catch { /* best-effort */ }
458
482
  }
459
- } finally { releaseLock(SESSIONS_LOCK); }
483
+ } finally { if (got) releaseLock(SESSIONS_LOCK); }
460
484
  } catch { /* coordination is best-effort */ }
461
485
  }
462
486
  // Other live lanes in THIS project (exclude me by session id, pid, AND host pid).
@@ -570,7 +594,7 @@ function postMessages(msgs) {
570
594
  kept.push(m);
571
595
  }
572
596
  fs.mkdirSync(SESSIONS_DIR, { recursive: true });
573
- fs.writeFileSync(SESSIONS_FILE, JSON.stringify({ ...data, sessions, messages: capMsgs(kept) }));
597
+ writeLaneAtomic(JSON.stringify({ ...data, sessions, messages: capMsgs(kept) }));
574
598
  } catch { /* best-effort */ } finally { if (got) releaseLock(SESSIONS_LOCK); }
575
599
  }
576
600
  // to==='all'/'' → everyone; else the hint must appear in my id-prefix / branch / intent.
@@ -690,14 +714,18 @@ function messageFooter(sid, tp, lib) {
690
714
  if (!Array.isArray(m.seen)) m.seen = [];
691
715
  if (!m.seen.includes(sid)) m.seen.push(sid);
692
716
  }
693
- fs.writeFileSync(SESSIONS_FILE, JSON.stringify(d2));
694
- } catch { /* */ } finally { releaseLock(SESSIONS_LOCK); }
717
+ writeLaneAtomic(JSON.stringify(d2));
718
+ } catch { /* */ } finally { if (got) releaseLock(SESSIONS_LOCK); }
695
719
  }
696
720
  if (!delivered.length) return '';
697
721
  const ago = (ts) => { const mm = Math.max(0, Math.round((now - (ts || now)) / 60000)); return mm <= 0 ? 'just now' : `${mm}m ago`; };
698
722
  const out = ['', '## 📨 Message(s) from another session in this project (delivered once — act on or reply to them)'];
723
+ // Neutralize glyph-keyword adjacency in delivered text (🧠·BRAIN can't match
724
+ // the capture regex): a forged lane row must never plant a harvestable marker
725
+ // in this session's prompt. Mirrors agent-presence.neutralizeMarkers.
726
+ const neutral = (s) => String(s).replace(/🧠(\s*)(BRAIN|MSG)/gi, '🧠·$2');
699
727
  for (const m of delivered) {
700
- out.push(`- from ${String(m.from || '?').slice(0, 8)} · ${ago(m.ts)}: ${String(m.text).replace(/\s+/g, ' ').trim().slice(0, 400)}`);
728
+ out.push(`- from ${String(m.from || '?').slice(0, 8)} · ${ago(m.ts)}: ${neutral(String(m.text)).replace(/\s+/g, ' ').trim().slice(0, 400)}`);
701
729
  // Engine-emitted LAST-KNOWN stamp — its own line, after the 400-char slice.
702
730
  const stamp = decayStampForMessage(m.text, m.ts, now, lib);
703
731
  if (stamp) out.push(` ${stamp}`);
@@ -1267,7 +1295,7 @@ const readRuleDrafts = () => { try { const d = JSON.parse(fs.readFileSync(RULE_D
1267
1295
  // pending finding and vice versa. Both writers now read the file first and
1268
1296
  // replace only their own key. Adding a third list is safe by the same rule.
1269
1297
  const readSidecar = () => { try { const d = JSON.parse(fs.readFileSync(RULE_DRAFTS, 'utf8')); return d && typeof d === 'object' && !Array.isArray(d) ? d : {}; } catch { return {}; } };
1270
- const writeSidecar = (patch) => { try { fs.mkdirSync(path.dirname(RULE_DRAFTS), { recursive: true }); fs.writeFileSync(RULE_DRAFTS, JSON.stringify({ ...readSidecar(), ...patch }, null, 2)); } catch { /* best-effort */ } };
1298
+ const writeSidecar = (patch) => { try { fs.mkdirSync(path.dirname(RULE_DRAFTS), { recursive: true }); const tmp = `${RULE_DRAFTS}.tmp-${process.pid}`; fs.writeFileSync(tmp, JSON.stringify({ ...readSidecar(), ...patch }, null, 2)); fs.renameSync(tmp, RULE_DRAFTS); } catch { /* best-effort */ } };
1271
1299
  const writeRuleDrafts = (drafts) => writeSidecar({ drafts: (drafts || []).slice(-RULE_DRAFTS_MAX) });
1272
1300
  const draftKey = (area, text) => sha(((area || '') + '|' + String(text)).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim());
1273
1301
  // Token overlap: is a draft seed substantially covered by a (skill) card's text?
@@ -1300,6 +1328,7 @@ function sessionVerified(shellCmds, errorIds, hadFixCommit) {
1300
1328
  // sidecar in an adopter project, and identical rewrites cause no churn.
1301
1329
  function persistDrafts(mutate) {
1302
1330
  const got = acquireLock(RULE_DRAFTS_LOCK, { tries: 20, waitMs: 25 });
1331
+ if (!got) return; // lock timeout → SKIP: an unlocked RMW clobbers the other key's writer; a dropped bump re-detects next Stop
1303
1332
  try {
1304
1333
  const now = Date.now();
1305
1334
  const raw = readRuleDrafts();
@@ -1447,6 +1476,7 @@ const readFindings = () => { const d = readSidecar(); return Array.isArray(d.fin
1447
1476
  // that keeps an adopter project from growing an empty sidecar.
1448
1477
  function persistFindings(mutate) {
1449
1478
  const got = acquireLock(RULE_DRAFTS_LOCK, { tries: 20, waitMs: 25 });
1479
+ if (!got) return; // lock timeout → SKIP (mirrors persistDrafts): never RMW the shared sidecar unlocked
1450
1480
  try {
1451
1481
  const now = Date.now();
1452
1482
  const raw = readFindings();
@@ -1764,7 +1794,10 @@ async function capture(lib) {
1764
1794
  // is this turn's real touch-set — the other half of "own scope".
1765
1795
  await draftFindingsFromCards(cards, verified, sid, recentPaths);
1766
1796
  }
1767
- if (!cards.length && !resolutions.length && !updates.length) {
1797
+ // A queued batch from a prior lock-refused capture counts as work to do —
1798
+ // the authoritative drain happens INSIDE the brain lock (doCapture), so two
1799
+ // concurrent sessions can never both land the same queued batch.
1800
+ if (!cards.length && !resolutions.length && !updates.length && !readPendingCaptures().length) {
1768
1801
  // Record the commit baseline / advance even with nothing to capture, so
1769
1802
  // the next run doesn't re-scan the same commits.
1770
1803
  if (newLastCommit && newLastCommit !== prevCommit) writeLastCommit(newLastCommit);
@@ -1782,30 +1815,68 @@ async function capture(lib) {
1782
1815
  // and UNION the dedup state (don't clobber a peer's seen-set). captureInto-
1783
1816
  // Brain supersedes heavily-overlapping old cards, applies ✓/~, routes new
1784
1817
  // cards into [Area] containers, and wires [[wikilink]] connections.
1785
- const gotLock = acquireLock(LOCK);
1786
- let stats;
1787
- try {
1818
+ const doCapture = async (locked) => {
1788
1819
  const merged = readState(); for (const k of seen) merged.add(k);
1820
+ if (!locked) {
1821
+ // REFUSED: never write from a stale base. The batch is queued durably
1822
+ // (same home as the dedup state) and drained by the next capture in
1823
+ // this project — deferred-but-safe beats immediate-but-clobbering.
1824
+ if (cards.length || resolutions.length || updates.length) {
1825
+ updatePendingCaptures((current) => [
1826
+ ...current,
1827
+ { id: `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}`, ts: nowIso(), cards, resolutions, updates },
1828
+ ]);
1829
+ }
1830
+ writeState(merged);
1831
+ writeLastCommit(newLastCommit); // safe: the batch itself is durably queued
1832
+ if (drainedShips && typeof lib.clearPendingShips === 'function') lib.clearPendingShips(CWD);
1833
+ advanceShipBaseline(lib);
1834
+ appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'capture', ok: false, err: 'lock-timeout — batch QUEUED for the next capture; brain untouched' }, 500);
1835
+ process.stderr.write('[brain] capture deferred: the brain lock is held (desktop save or peer capture) — batch queued durably, nothing lost\n');
1836
+ return null;
1837
+ }
1838
+ // Drain the queue UNDER the lock: read, land, clear-by-id — a peer's
1839
+ // batch queued after this read survives, and no batch lands twice.
1840
+ const pendingBatches = readPendingCaptures();
1841
+ const drainedPendingIds = new Set(pendingBatches.map(b => b && b.id).filter(Boolean));
1842
+ for (const b of pendingBatches) {
1843
+ if (!b || typeof b !== 'object') continue;
1844
+ for (const c of (Array.isArray(b.cards) ? b.cards : [])) cards.push(c);
1845
+ for (const r of (Array.isArray(b.resolutions) ? b.resolutions : [])) resolutions.push(r);
1846
+ for (const u of (Array.isArray(b.updates) ? b.updates : [])) updates.push(u);
1847
+ }
1848
+ if (!cards.length && !resolutions.length && !updates.length) return null;
1789
1849
  const res = await lib.captureIntoBrain(fs.readFileSync(BRAIN), {
1790
1850
  cards: cards.map(c => ({ text: c.text, color: '#e8e8ed', borderColor: c.borderColor, area: c.area, createdVia: c.createdVia || 'claude-code', ...(c.closes ? { closes: c.closes } : {}), ...(c.evidence ? { evidence: c.evidence } : {}), ...(c.verify ? { verify: c.verify } : {}) })),
1791
1851
  resolutions,
1792
1852
  updates,
1793
1853
  });
1794
- stats = res.stats;
1795
1854
  // Re-pack the whole grid so a container that grew never overlaps its neighbor.
1796
1855
  let out = res.buffer; try { out = (await lib.tidyBrain(res.buffer)).buffer; } catch { /* keep append result if tidy fails */ }
1797
1856
  await lib.atomicWrite(BRAIN, out);
1798
1857
  writeState(merged);
1799
1858
  writeLastCommit(newLastCommit); // advance the commit baseline only after a successful write
1859
+ if (drainedPendingIds.size) updatePendingCaptures((current) => current.filter(b => b && !drainedPendingIds.has(b.id)));
1800
1860
  // Same discipline for the two ship channels: the queue is consumed and
1801
1861
  // the observation baseline advances ONLY now that the cards are durable.
1802
1862
  if (drainedShips && typeof lib.clearPendingShips === 'function') lib.clearPendingShips(CWD);
1803
1863
  advanceShipBaseline(lib);
1804
1864
  try { await refreshAgentsBrief(lib, out); } catch { /* AGENTS.md refresh is best-effort */ }
1805
- } finally {
1806
- if (gotLock) releaseLock(LOCK);
1865
+ return res.stats;
1866
+ };
1867
+ // Canonical cross-process lock (heartbeat + token-checked release, shared
1868
+ // with the MCP engine and the desktop app). Stale bundles missing the module
1869
+ // fall back to the local lock — but ALWAYS refuse-and-queue on timeout.
1870
+ let lockLib = null;
1871
+ try { lockLib = await import(new URL('./brain-write-lock.mjs', import.meta.url).href); } catch { /* stale deployment */ }
1872
+ let stats;
1873
+ if (lockLib && typeof lockLib.withAdvisoryWriteLock === 'function') {
1874
+ stats = await lockLib.withAdvisoryWriteLock(lockLib.brainCaptureLockPath(BRAIN), doCapture, { tries: 100, waitMs: 60 });
1875
+ } else {
1876
+ const gotLock = acquireLock(LOCK);
1877
+ try { stats = await doCapture(gotLock); } finally { if (gotLock) releaseLock(LOCK); }
1807
1878
  }
1808
- if (!gotLock) appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'capture', ok: false, err: 'lock-timeout — wrote best-effort' }, 500);
1879
+ if (!stats) return;
1809
1880
  const bits = [`${stats.added} added`];
1810
1881
  if (stats.resolved) bits.push(`${stats.resolved} resolved`);
1811
1882
  if (stats.updated) bits.push(`${stats.updated} updated`);
@@ -34,7 +34,7 @@ import {
34
34
  isFastDecayCard, DECAY_STALE_MS, formatDecayAge,
35
35
  readPendingShips, clearPendingShips, pendingShipCards, formatCaptureReceipts,
36
36
  } from './klypix-format.mjs';
37
- import { capMessages, findProjectBrain } from './agent-presence.mjs';
37
+ import { capMessages, findProjectBrain, neutralizeMarkers } from './agent-presence.mjs';
38
38
  import { brainCaptureLockPath, vaultCreateLockPath, withAdvisoryWriteLock } from './brain-write-lock.mjs';
39
39
  import {
40
40
  dot, embedTexts, getEmbedder, getEmbedderForUse, withRerankerForUse,
@@ -288,7 +288,10 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
288
288
  const asOfTs = as_of ? Date.parse(as_of) : null;
289
289
  if (as_of && Number.isNaN(asOfTs)) return err(`Bad as_of date: "${as_of}" — use YYYY-MM-DD.`);
290
290
 
291
- const pipe = await getEmbedderForUse(log, 20_000);
291
+ // Queue saturation (KLYPIX_SEMANTIC_QUEUE_FULL) must degrade to lexical, not
292
+ // error — this tool's own description promises "degrades cleanly".
293
+ let pipe = null;
294
+ try { pipe = await getEmbedderForUse(log, 20_000); } catch { /* saturated/unavailable → lexical only */ }
292
295
  let qv = null;
293
296
  if (pipe) { try { [qv] = await embedTexts(pipe, [q], { kind: 'query' }); } catch { /* lexical only */ } }
294
297
 
@@ -895,7 +898,9 @@ export async function opBrainConnect({ vault, canvas, apply = false, max = 24, t
895
898
  : (normalizedScope === 'orphans' ? 0.55 : 0.45);
896
899
  let edges = [];
897
900
  let mode = 'structural (shared tags + [[mentions]])';
898
- const pipe = await getEmbedderForUse(log, 20_000);
901
+ // Queue saturation must degrade to the structural mode, never a hard error.
902
+ let pipe = null;
903
+ try { pipe = await getEmbedderForUse(log, 20_000); } catch { /* saturated/unavailable → structural */ }
899
904
  if (pipe) {
900
905
  try {
901
906
  const vecs = await vectorsForBrain(pipe, file, struct.cards);
@@ -1132,9 +1137,15 @@ export async function opBrainMessage({ vault, canvas, text: msgText, to, via })
1132
1137
  // cap + collapse the target hint too — an oversized `to` would bloat the lane
1133
1138
  // file every hook of every session re-reads for 24h (and matches no one anyway)
1134
1139
  to: String(to || 'all').replace(/\s+/g, ' ').trim().slice(0, 64) || 'all',
1135
- text: txt.slice(0, 400), ts: now, seen: [],
1140
+ // Neutralized at post (delivery surfaces neutralize again for forged rows):
1141
+ // a message must never carry a HARVESTABLE capture marker into a peer's prompt.
1142
+ text: neutralizeMarkers(txt.slice(0, 400)), ts: now, seen: [],
1136
1143
  };
1137
1144
  const got = laneLock(lock);
1145
+ // Lock timeout → REFUSE, never write: an unlocked read-modify-write of the
1146
+ // whole lane can permanently erase a peer's just-posted undelivered message
1147
+ // (the exact loss class touchSession already refuses). Retry is cheap.
1148
+ if (!got) return err('brain_message deferred: the coordination lane is busy (another session is writing). Nothing was posted — retry in a moment.');
1138
1149
  let live = 0;
1139
1150
  try {
1140
1151
  let data = {}; try { data = JSON.parse(fs.readFileSync(laneFile, 'utf8')); } catch { /* fresh lane */ }
@@ -1146,10 +1157,13 @@ export async function opBrainMessage({ vault, canvas, text: msgText, to, via })
1146
1157
  // Delivered-first eviction + ...data spread — the other two lane writers were
1147
1158
  // converted in the 2026-07-29 overhaul; a flat slice here still destroyed the
1148
1159
  // oldest UNDELIVERED note at cap (review-caught).
1149
- fs.writeFileSync(laneFile, JSON.stringify({ ...data, sessions, messages: capMessages(kept, 30) }));
1160
+ // tmp+rename so lock-free readers can never parse a torn lane as "no messages".
1161
+ const tmp = `${laneFile}.tmp-${process.pid}`;
1162
+ fs.writeFileSync(tmp, JSON.stringify({ ...data, sessions, messages: capMessages(kept, 30) }));
1163
+ fs.renameSync(tmp, laneFile);
1150
1164
  } catch (e) {
1151
1165
  return err(`brain_message failed: ${e.message}`);
1152
- } finally { if (got) { try { fs.unlinkSync(lock); } catch { /* */ } } }
1166
+ } finally { try { fs.unlinkSync(lock); } catch { /* */ } }
1153
1167
  return { blocks: [text(`📨 posted to this project's coordination lane (to: ${msg.to}) — ${live} active presence-wired session(s) right now; each receives it once through its lifecycle adapter or next brain_sync / KLYPIX tool call. Ephemeral (24h), not a brain card — use brain_note for durable decisions.`)] };
1154
1168
  }
1155
1169
 
@@ -474,7 +474,22 @@ export function createMcpPresence({
474
474
  files: details.files,
475
475
  replaceFiles: details.replaceFiles === true,
476
476
  });
477
- timer = setIntervalFn(() => touch(), Math.max(5_000, Number(heartbeatMs) || MCP_HEARTBEAT_MS));
477
+ // Consume the write verdict (1.52.0 plumbed it; nothing read it): a
478
+ // contended lane skips the write, and ~3 skipped heartbeats in a row used
479
+ // to make a LIVE session read as dead to every peer, silently. One
480
+ // immediate same-tick retry clears transient contention; persistent
481
+ // failure is surfaced through the server's logging channel.
482
+ let missedLaneWrites = 0;
483
+ timer = setIntervalFn(() => {
484
+ let beat = touch();
485
+ if (beat?.laneWriteOk === false) beat = touch(); // one quick retry
486
+ if (beat?.laneWriteOk !== false) { missedLaneWrites = 0; return; }
487
+ missedLaneWrites++;
488
+ if (missedLaneWrites >= 3) {
489
+ try { server?.server?.sendLoggingMessage?.({ level: 'warning', data: `KLYPIX presence heartbeat skipped ${missedLaneWrites}× (lane contended) — peers may briefly see this session as idle.` }); } catch { /* logging is best-effort */ }
490
+ missedLaneWrites = 0;
491
+ }
492
+ }, Math.max(5_000, Number(heartbeatMs) || MCP_HEARTBEAT_MS));
478
493
  inboxTimer = setIntervalFn(
479
494
  () => pollInbox(),
480
495
  Math.max(250, effectiveInboxPollMs),
@@ -251,6 +251,13 @@ class Supervisor {
251
251
  this.stateFile = path.join(this.stateDir, `${process.pid}.json`);
252
252
  this.connectionId = String(options.connectionId || crypto.randomUUID());
253
253
  this.parentPid = Number(process.ppid) || null;
254
+ // Default-root detection: IDE hosts often launch from their install dir
255
+ // with no --vault/KLYPIX_VAULT, so the pair boots against ~/Documents and
256
+ // idles there. Flagging it in the state file lets doctor/runtime name these
257
+ // pairs explicitly instead of them hiding inside the aggregate RAM number.
258
+ const vaultFlagAt = this.workerArgs ? this.workerArgs.indexOf('--vault') : -1;
259
+ this.vaultArg = vaultFlagAt >= 0 ? String(this.workerArgs[vaultFlagAt + 1] || '') : null;
260
+ this.defaultRoot = !this.vaultArg && !process.env.KLYPIX_VAULT;
254
261
  this.clientInfo = null;
255
262
  this.lastHostMessageAt = null;
256
263
  this.lastActivityStateWriteAt = 0;
@@ -288,6 +295,8 @@ class Supervisor {
288
295
  pid: process.pid,
289
296
  connectionId: this.connectionId,
290
297
  parentPid: this.parentPid,
298
+ vault: this.vaultArg ? this.vaultArg.replace(/\\/g, '/') : null,
299
+ defaultRoot: this.defaultRoot,
291
300
  cwd: process.cwd().replace(/\\/g, '/'),
292
301
  bootedAt: this.bootedAt,
293
302
  updatedAt: new Date().toISOString(),
@@ -502,13 +511,26 @@ class Supervisor {
502
511
  }
503
512
 
504
513
  failInflight(message) {
505
- for (const id of this.hostRequests.values()) {
514
+ for (const { id } of this.hostRequests.values()) {
506
515
  this.sendHost({ jsonrpc: '2.0', id, error: { code: -32603, message } });
507
516
  }
508
517
  this.hostRequests.clear();
509
518
  this.workerRequests.clear();
510
519
  }
511
520
 
521
+ // A host request the worker never answers must not pin active+candidate
522
+ // workers (2× RAM) forever: past the deadline we answer the host with an
523
+ // error (its own client timeout has long fired) and stop counting it against
524
+ // candidate commit.
525
+ expireAbandonedRequests(maxAgeMs = 120_000) {
526
+ const cutoff = Date.now() - maxAgeMs;
527
+ for (const [key, entry] of this.hostRequests) {
528
+ if ((entry?.ts || 0) >= cutoff) continue;
529
+ this.hostRequests.delete(key);
530
+ this.sendHost({ jsonrpc: '2.0', id: entry.id, error: { code: -32603, message: 'KLYPIX worker did not answer this request within 120s; it was abandoned so a pending worker swap can proceed.' } });
531
+ }
532
+ }
533
+
512
534
  captureTaskScope(message) {
513
535
  if (message?.method !== 'tools/call' || message.params?.name !== 'brain_sync') return;
514
536
  const args = message.params?.arguments || {};
@@ -581,7 +603,7 @@ class Supervisor {
581
603
  this.captureTaskScope(message);
582
604
 
583
605
  if (message?.method && Object.prototype.hasOwnProperty.call(message, 'id')) {
584
- this.hostRequests.set(idKey(message.id), message.id);
606
+ this.hostRequests.set(idKey(message.id), { id: message.id, ts: Date.now() });
585
607
  } else if (!message?.method && Object.prototype.hasOwnProperty.call(message, 'id')) {
586
608
  this.workerRequests.delete(idKey(message.id));
587
609
  }
@@ -731,6 +753,7 @@ class Supervisor {
731
753
 
732
754
  maybeCommitCandidate() {
733
755
  if (!this.candidate?.ready) return;
756
+ this.expireAbandonedRequests();
734
757
  if (this.hostRequests.size || this.workerRequests.size) return;
735
758
  const next = this.candidate;
736
759
  const previous = this.active;
@@ -885,6 +908,20 @@ class Supervisor {
885
908
  );
886
909
  this.autoUpdatePoller.unref?.();
887
910
 
911
+ // Host watchdog: shutdown is otherwise 100% stdin-EOF-dependent, and a
912
+ // host that dies holding pipes open (or a wedged IDE) pinned this pair —
913
+ // supervisor AND worker — indefinitely. The parent pid is a cheap,
914
+ // platform-neutral liveness signal; EPERM still means alive.
915
+ if (this.parentPid && this.parentPid > 1) {
916
+ this.parentWatchdog = setInterval(() => {
917
+ try { process.kill(this.parentPid, 0); }
918
+ catch (error) {
919
+ if (error?.code !== 'EPERM') { log('host process is gone — closing the connection pair'); this.close(); }
920
+ }
921
+ }, 30_000);
922
+ this.parentWatchdog.unref?.();
923
+ }
924
+
888
925
  await new Promise(resolve => {
889
926
  this.resolveRun = resolve;
890
927
  process.stdin.once('end', () => this.close());
@@ -898,12 +935,22 @@ class Supervisor {
898
935
  if (this.closed) return;
899
936
  this.closed = true;
900
937
  clearInterval(this.poller);
938
+ clearInterval(this.parentWatchdog);
901
939
  clearTimeout(this.autoUpdateStarter);
902
940
  clearInterval(this.autoUpdatePoller);
903
941
  if (this.recoveryTimer) { clearTimeout(this.recoveryTimer); this.recoveryTimer = null; }
904
- for (const worker of [this.active, this.candidate, this.standby]) this.retireWorker(worker, 0);
942
+ // Real shutdown grace: stdin EOF lets the worker run its own presence
943
+ // cleanup (stopRuntimePresence/removeSession). An instant SIGTERM is
944
+ // TerminateProcess on Windows — the cleanup never runs and every normally
945
+ // closed session leaves a ghost "live" lane row for the TTL window (the
946
+ // "cleans up automatically" claim was false on exactly this path). SIGTERM
947
+ // stays as the 350ms backstop; the deliberately NOT-unref'd exit delay
948
+ // holds this process open just long enough to deliver it.
949
+ const workers = [this.active, this.candidate, this.standby].filter(w => w && !w.exited);
950
+ for (const worker of workers) this.retireWorker(worker, 350);
905
951
  try { fs.unlinkSync(this.stateFile); } catch { /* */ }
906
- this.resolveRun?.();
952
+ if (workers.length) setTimeout(() => this.resolveRun?.(), 400);
953
+ else this.resolveRun?.();
907
954
  }
908
955
  }
909
956
 
@@ -196,6 +196,33 @@ export function selectOutboundMessages(messages, { sinceTs = 0 } = {}) {
196
196
  && !String(message.dedupeKey || '').startsWith(XPC_DEDUPE_PREFIX));
197
197
  }
198
198
 
199
+ // ── Frame authenticity (optional per-brain MAC) ─────────────────────────────
200
+ // Channel ACLs live outside this repo, so any channel member could otherwise
201
+ // forge sid/machine (fabricated presence, overlap-warning spam) or claim a
202
+ // victim receiver's machineId so that receiver drops the frames as "own echo"
203
+ // (a targeted mute). With a shared per-brain key (the desktop already holds
204
+ // one for each cloud-linked brain), frames carry an HMAC over their sorted
205
+ // fields; a receiver configured with the key DROPS unsigned or mis-signed
206
+ // frames. No key configured → unsigned tolerated (compat: old builds, and
207
+ // consent remains the outer gate either way).
208
+ function frameMac(frame, key) {
209
+ const entries = Object.keys(frame).filter((k) => k !== 'mac').sort()
210
+ .map((k) => [k, frame[k]]);
211
+ return crypto.createHmac('sha256', String(key)).update(JSON.stringify(entries)).digest('hex').slice(0, 32);
212
+ }
213
+ export function signFrame(frame, key) {
214
+ if (!frame || typeof frame !== 'object' || !key) return frame;
215
+ return { ...frame, mac: frameMac(frame, key) };
216
+ }
217
+ export function verifyFrame(frame, key) {
218
+ if (!frame || typeof frame !== 'object') return false;
219
+ if (!key) return true; // no key → tolerate unsigned
220
+ if (typeof frame.mac !== 'string' || !frame.mac) return false;
221
+ const expected = Buffer.from(frameMac(frame, key));
222
+ const presented = Buffer.from(String(frame.mac));
223
+ return presented.length === expected.length && crypto.timingSafeEqual(presented, expected);
224
+ }
225
+
199
226
  // ── The transport seam ───────────────────────────────────────────────────────
200
227
  // The desktop relay calls exactly these two functions around its Realtime
201
228
  // channel; the conformance harness calls them around a mock. Consent is
@@ -214,6 +241,7 @@ export function relayOutbound({
214
241
  sinceTs = 0,
215
242
  now = Date.now(),
216
243
  send,
244
+ key = null,
217
245
  } = {}) {
218
246
  if (!presenceConsentAllows(consent)) return { sent: 0, reason: 'no-consent', maxMessageTs: sinceTs };
219
247
  if (typeof send !== 'function') return { sent: 0, reason: 'no-channel', maxMessageTs: sinceTs };
@@ -222,22 +250,23 @@ export function relayOutbound({
222
250
  for (const session of Array.isArray(sessions) ? sessions : []) {
223
251
  const frame = buildPresenceFrame(session, { machineId, hostLabel, root, now });
224
252
  if (!frame) continue;
225
- send(frame);
253
+ send(signFrame(frame, key));
226
254
  sent++;
227
255
  }
228
256
  for (const message of selectOutboundMessages(messages, { sinceTs })) {
229
257
  const frame = buildMessageFrame(message, { machineId, now });
230
258
  if (!frame) continue;
231
- send(frame);
259
+ send(signFrame(frame, key));
232
260
  sent++;
233
261
  maxMessageTs = Math.max(maxMessageTs, Number(message.ts || 0));
234
262
  }
235
263
  return { sent, reason: null, maxMessageTs };
236
264
  }
237
265
 
238
- export function relayInbound(frame, { consent = null, machineId, now = Date.now() } = {}) {
266
+ export function relayInbound(frame, { consent = null, machineId, now = Date.now(), key = null } = {}) {
239
267
  if (!presenceConsentAllows(consent)) return null; // symmetric: no consent ⇒ no receive-display
240
268
  if (!frame || typeof frame !== 'object') return null;
269
+ if (!verifyFrame(frame, key)) return null; // key configured → unsigned/mis-signed frames drop
241
270
  if (frame.kind === 'presence') {
242
271
  const row = acceptPresenceFrame(frame, { machineId, now });
243
272
  return row ? { type: 'presence', row } : null;
@@ -75,7 +75,13 @@ function safeSourceFile(root, value) {
75
75
 
76
76
  function shortString(value, max = 1_000) {
77
77
  if (value == null) return '';
78
- return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '').slice(0, max);
78
+ // Tabs and newlines collapse to one space so a hostile graph label can never
79
+ // break out of its one-line markdown bullet into headings of its own.
80
+ return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
81
+ .replace(/[\t\n\r]+/g, ' ')
82
+ .replace(/ {2,}/g, ' ')
83
+ .trim()
84
+ .slice(0, max);
79
85
  }
80
86
 
81
87
  function normalizeConfidence(edge) {
@@ -384,6 +390,22 @@ export function suggestProjectGraphBrainLinks(graphResult, brainContext, limit =
384
390
  return proposals;
385
391
  }
386
392
 
393
+ const STALE_ARTIFACT_MS = 7 * 24 * 60 * 60 * 1000;
394
+
395
+ function artifactAgeLine(artifact) {
396
+ const iso = artifact?.modifiedAt;
397
+ if (!iso) return '';
398
+ const generated = Date.parse(iso);
399
+ if (!Number.isFinite(generated)) return '';
400
+ const ageMs = Math.max(0, Date.now() - generated);
401
+ const days = Math.floor(ageMs / (24 * 60 * 60 * 1000));
402
+ const age = days >= 1 ? `${days} day(s) ago` : 'today';
403
+ const warning = ageMs > STALE_ARTIFACT_MS
404
+ ? ' — the artifact may be stale; regenerate it if the code has moved.'
405
+ : '.';
406
+ return `Artifact generated ${iso} (${age})${warning} Graph evidence is only as current as this artifact.`;
407
+ }
408
+
387
409
  export function projectGraphContextMarkdown(result) {
388
410
  if (result.status === 'missing') {
389
411
  return `# Project Map\n\nNo supported project graph was found at \`${result.artifact.graphJson}\`. KLYPIX did not install or run a provider. Generate the artifact with Graphify, then retry.`;
@@ -400,6 +422,7 @@ export function projectGraphContextMarkdown(result) {
400
422
  return [
401
423
  '# Project Map: code evidence',
402
424
  `Provider artifact: \`${result.artifact.graphJson}\`; ${result.counts.graphNodes.toLocaleString()} nodes; ${result.counts.graphEdges.toLocaleString()} edges; query \`${result.query || '(overview)'}\`.`,
425
+ artifactAgeLine(result.artifact),
403
426
  warnings.length ? `Safety note: ${warnings.join('; ')}.` : '',
404
427
  result.change ? `## Change from \`${result.change.comparedArtifact || 'comparison artifact'}\`\nExact total deltas: ${result.change.graphNodesDelta >= 0 ? '+' : ''}${result.change.graphNodesDelta} nodes; ${result.change.graphEdgesDelta >= 0 ? '+' : ''}${result.change.graphEdgesDelta} edges. Named additions and removals are limited to the two bounded query neighborhoods.` : '',
405
428
  result.change?.addedNodes?.length ? `Added in bounded result: ${result.change.addedNodes.slice(0, 20).map(node => `\`${node.label || node.id}\``).join(', ')}.` : '',