klypix-mcp 1.55.0 → 1.57.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.57.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 */ }