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 +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 +51 -4
- package/src/presence-relay.mjs +32 -3
- package/src/project-graph.mjs +24 -1
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.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"
|
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 */ }
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1806
|
-
|
|
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 (!
|
|
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`);
|
package/src/klypix-core.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
package/src/mcp-presence.mjs
CHANGED
|
@@ -474,7 +474,22 @@ export function createMcpPresence({
|
|
|
474
474
|
files: details.files,
|
|
475
475
|
replaceFiles: details.replaceFiles === true,
|
|
476
476
|
});
|
|
477
|
-
|
|
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),
|
package/src/mcp-supervisor.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/presence-relay.mjs
CHANGED
|
@@ -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;
|
package/src/project-graph.mjs
CHANGED
|
@@ -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
|
-
|
|
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(', ')}.` : '',
|