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 +11 -4
- package/README.md +3 -1
- package/bin/klypix-a2a.mjs +49 -1
- package/bin/klypix-append.mjs +16 -6
- package/bin/klypix-worker.mjs +58 -13
- package/package.json +2 -2
- package/src/agent-presence.mjs +29 -8
- package/src/brain-git-hook.mjs +23 -28
- package/src/brain-note.mjs +16 -4
- package/src/brain-write-lock.mjs +15 -1
- package/src/global-brain-hook.mjs +86 -15
- package/src/klypix-core.mjs +20 -6
- package/src/mcp-presence.mjs +16 -1
- package/src/mcp-supervisor.mjs +234 -5
- package/src/presence-relay.mjs +32 -3
- package/src/project-graph.mjs +24 -1
- package/src/runtime-inspector.mjs +29 -2
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
package/bin/klypix-a2a.mjs
CHANGED
|
@@ -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, {
|
|
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.
|
package/bin/klypix-append.mjs
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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}.`);
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -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:
|
|
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:
|
|
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 =
|
|
295
|
-
?
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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(() =>
|
|
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.
|
|
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"
|
package/src/agent-presence.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/brain-git-hook.mjs
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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
|
|
99
|
-
// against commits ALREADY in the brain so neither a re-run nor
|
|
100
|
-
// double-records the same commit.
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
}
|
|
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));
|
package/src/brain-note.mjs
CHANGED
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
await
|
|
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}`);
|
package/src/brain-write-lock.mjs
CHANGED
|
@@ -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
|
-
|
|
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 */ }
|