klypix-mcp 1.89.0 → 1.90.1
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/README.md +61 -17
- package/bin/klypix-doctor.mjs +8 -3
- package/bin/klypix-install.mjs +57 -3
- package/bin/klypix-worker.mjs +58 -6
- package/package.json +2 -2
- package/src/agent-presence.mjs +63 -20
- package/src/brain-doctor.mjs +850 -43
- package/src/global-brain-hook.mjs +352 -73
- package/src/mcp-auto-update.mjs +1184 -173
- package/src/mcp-presence.mjs +1 -1
- package/src/mcp-supervisor.mjs +909 -93
- package/src/runtime-inspector.mjs +83 -6
package/README.md
CHANGED
|
@@ -460,8 +460,8 @@ receiving model calls `brain_message_receipt` with the exact message id and per-
|
|
|
460
460
|
token; only that token-bound action records `consumed`. Pending, offered, and acknowledged notes
|
|
461
461
|
survive reconnects. Expiry or bounded-capacity eviction records a failed per-recipient receipt
|
|
462
462
|
instead of silently looking delivered. The send-time audience is fixed, unresolved targeted sends
|
|
463
|
-
fail closed, the core lane is machine-local,
|
|
464
|
-
into the brain.
|
|
463
|
+
fail closed, the core lane is machine-local, a note to every session expires after 24 hours and a
|
|
464
|
+
directed note after 7 days, and notes are never written into the brain.
|
|
465
465
|
|
|
466
466
|
Durable handoffs go in the brain itself — decisions, findings, open questions and skills captured
|
|
467
467
|
as cards, each stamped with the agent that wrote it.
|
|
@@ -750,19 +750,59 @@ replaceable worker runs the brain core. A staged update is hash-verified, initia
|
|
|
750
750
|
checked for backward-compatible tool schemas, and handed the current `brain_sync` task scope before
|
|
751
751
|
the supervisor switches between requests. Added tools use the standard
|
|
752
752
|
`notifications/tools/list_changed` signal. A failed or breaking candidate is rejected while the old
|
|
753
|
-
worker keeps serving. A
|
|
754
|
-
|
|
753
|
+
worker keeps serving. A connection idle for 10 minutes releases its worker and keeps its presence;
|
|
754
|
+
it stays asleep until its host sends a request, and the new worker it then starts passes the same
|
|
755
|
+
checks. If they reject it, the connection resumes the version it last ran, or answers with a
|
|
756
|
+
retryable `/mcp reconnect` error rather than restarting in a loop. A blocked result claim is kept
|
|
757
|
+
in a durable per-project/session marker, so a worker replacement cannot turn a failed evidence
|
|
758
|
+
check into a result-less completion.
|
|
755
759
|
|
|
756
760
|
Compatible engine updates therefore activate behind the same live connection — no reconnect, no
|
|
757
761
|
host restart. Three cases still require a deliberate reconnect or manual install: the one-time
|
|
758
|
-
legacy→supervisor migration, a supervisor-code change, and a major or tool-removing release.
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
762
|
+
legacy→supervisor migration, a supervisor-code change, and a major or tool-removing release. Fixes
|
|
763
|
+
to the supervisor itself reach a connection only after one `/mcp` reconnect or a host restart;
|
|
764
|
+
worker and doctor changes hot-swap. `brain_doctor` reports the live supervisors (including how many
|
|
765
|
+
still run older supervisor code) and the automatic-update schedule: the last result, which install
|
|
766
|
+
it describes, and when the next check runs. The MCP tool also returns these as structured data.
|
|
767
|
+
|
|
768
|
+
The updater checks npm **once per machine every 6 hours**, however many sessions are open. It
|
|
769
|
+
checks once more, never sooner than 5 minutes after the last attempt, when another install upgrades
|
|
770
|
+
this runtime or moves it from a developer deploy to a released install. Examples are a manual
|
|
771
|
+
`npx klypix-mcp install` of a newer release, or a release replacing a developer deploy. A failed
|
|
772
|
+
check retries after 15 minutes, then 1 hour, then 4 hours, then returns to the 6-hour cadence. Each
|
|
773
|
+
attempt is recorded as failed before it touches the network, so a check that dies part-way backs
|
|
774
|
+
off instead of retrying in a loop. Checks run from the open KLYPIX sessions: the supervisor and
|
|
775
|
+
worker look every 10 minutes, and a new session looks 2 seconds after it starts.
|
|
776
|
+
|
|
777
|
+
The updater installs an exact stable release of the **same major version** in `--runtime-only`
|
|
778
|
+
mode, preserving host settings and project files; a new major always needs a manual install. It
|
|
779
|
+
never downgrades. A deliberate downgrade (`npx klypix-mcp@<older> install --force`) onto a release
|
|
780
|
+
newer than 1.89.0 is **held**: the updater does not re-install the version it was rolled back from,
|
|
781
|
+
only a newer release. To take the held version back, run `npx -y klypix-mcp@latest install` (or set
|
|
782
|
+
`KLYPIX_AUTO_UPDATE_FORCE=1` where `KLYPIX_AUTO_UPDATE` is set, below, for one check, then remove
|
|
783
|
+
it). A rollback onto 1.89.0 or earlier also rolls the updater back, and those releases have no
|
|
784
|
+
hold: they re-install the newest same-major release within 24 hours of their last check. To stay
|
|
785
|
+
on such a release, set `KLYPIX_AUTO_UPDATE=0` in every place listed below for as long as you stay;
|
|
786
|
+
`brain_doctor` warns when a downgrade is not held. The updater never fetches anything for a
|
|
787
|
+
developer-owned install and never installs over it. The check is detached and fail-open, and
|
|
788
|
+
concurrent sessions collapse behind one lock.
|
|
789
|
+
|
|
790
|
+
`KLYPIX_AUTO_UPDATE=0` opts out. Every process reads its own environment, so set it in each host's
|
|
791
|
+
launch environment: the `env` of each KLYPIX MCP server entry, and the environment Claude Code runs
|
|
792
|
+
its hooks in. It takes effect at that host's next supervisor start or `/mcp` reconnect.
|
|
793
|
+
|
|
794
|
+
There is one second, smaller probe. In a brain project (a directory with `./brain.klypix`), the
|
|
795
|
+
Claude Code Stop hook refreshes a local npm-version cache, which the next SessionStart reads to say
|
|
796
|
+
whether an update is available. That notice also says what the updater will do with the update,
|
|
797
|
+
and when. The probe:
|
|
798
|
+
|
|
799
|
+
- runs at most once a day, developer-owned installs included;
|
|
800
|
+
- makes the same kind of anonymous request the updater makes, a GET of
|
|
801
|
+
`https://registry.npmjs.org/klypix-mcp/latest` that carries no user or machine identifier;
|
|
802
|
+
- is skipped while the updater fetched npm's latest version less than a day ago;
|
|
803
|
+
- is off with the same `KLYPIX_AUTO_UPDATE=0`, read from the environment Claude Code runs its hooks
|
|
804
|
+
in;
|
|
805
|
+
- never installs anything.
|
|
766
806
|
|
|
767
807
|
When the optional semantic runtime is already enabled, an update also schedules one detached,
|
|
768
808
|
single-writer cache migration across registered brains. That removes the multi-minute first-query
|
|
@@ -774,9 +814,13 @@ keep lazy first-use indexing instead.
|
|
|
774
814
|
|
|
775
815
|
- **Apache-2.0, source public** at [github.com/dahshanlabs/klypix-mcp](https://github.com/dahshanlabs/klypix-mcp).
|
|
776
816
|
- **The brain engine makes no network calls and sends no telemetry.** All engine intelligence is
|
|
777
|
-
deterministic and local; the only LLM anywhere is *your* agent. The
|
|
778
|
-
is the
|
|
779
|
-
|
|
817
|
+
deterministic and local; the only LLM anywhere is *your* agent. The exceptions in this package
|
|
818
|
+
are the two update probes described above. One is the updater's npm version check, every 6
|
|
819
|
+
hours, plus one re-check after another install upgrades the runtime, and retries after a failed
|
|
820
|
+
check (15 minutes, 1 hour, 4 hours); when it finds a newer same-major release, it also runs that
|
|
821
|
+
release's npm install. The other is the Claude Code Stop hook's version probe, at most once a day
|
|
822
|
+
in brain projects. Both are the same kind of anonymous GET of the package's `latest` version, and
|
|
823
|
+
`KLYPIX_AUTO_UPDATE=0` turns both off.
|
|
780
824
|
- **The optional semantic model runs on device.** Enabling it (or upgrading its model) can fetch
|
|
781
825
|
model weights from Hugging Face; retrieval inference and brain data stay local.
|
|
782
826
|
- **Coordination state is local files.** The brain is a file in your repo; the presence lane is a
|
|
@@ -869,8 +913,8 @@ independently validated.
|
|
|
869
913
|
fewer than four content words: junk injection 93% → 35%, mean cards injected on junk 3.6 → 1.5,
|
|
870
914
|
at a one-question cost on the 35 real prompts (inside noise). The prompts stay private; the
|
|
871
915
|
sweep tables are in the source next to the bars they chose.
|
|
872
|
-
- **What we do not publish.** No download count: this package's own
|
|
873
|
-
|
|
916
|
+
- **What we do not publish.** No download count: this package's own auto-updater generates most
|
|
917
|
+
of it, so it is not a user count. No adoption, team or customer figures. No brief-token
|
|
874
918
|
figure — the last one was measured at ~600 cards and is stale at 2,479.
|
|
875
919
|
- **The eval harness is not in this repo.** It lives in the private KLYPIX desktop repository. The
|
|
876
920
|
numbers above are ours to defend, not yours to reproduce from here — treat them accordingly.
|
package/bin/klypix-doctor.mjs
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// klypix-doctor — `npx klypix-mcp doctor`. The brain's self-check: is THIS machine's
|
|
3
3
|
// brain current, are the Claude capture and Codex presence adapters wired, what verbs
|
|
4
4
|
// does it expose, which lifecycle sessions are live, and is the harness projection in
|
|
5
|
-
// sync? ONE fact, ONE reconcile block. Read-only (never writes). Exits 0 = ALIGNED
|
|
6
|
-
// 1 = DRIFTED — so it
|
|
5
|
+
// sync? ONE fact, ONE reconcile block. Read-only (never writes). Exits 0 = ALIGNED
|
|
6
|
+
// or PARTIAL (readiness warnings, no drift), 1 = DRIFTED — so it
|
|
7
7
|
// doubles as a pre-commit / CI readiness gate.
|
|
8
8
|
//
|
|
9
9
|
// npx klypix-mcp doctor # this project + this machine's brain
|
|
@@ -63,9 +63,14 @@ try {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
const drifted = report.verdict === 'DRIFTED' || extraDrift > 0;
|
|
66
|
+
// PARTIAL used to end with "✓ aligned." under a head that said PARTIAL
|
|
67
|
+
// (2026-10-03) — the closing line is what people read. Exit code unchanged.
|
|
68
|
+
const warnings = Array.isArray(report.readinessWarnings) ? report.readinessWarnings.length : 0;
|
|
66
69
|
console.log(drifted ? (color ? '\x1b[33m' : '') + `\n✗ drift found — see reconcile above.` + (color ? '\x1b[0m' : '')
|
|
67
70
|
: report.verdict === 'NOT-INSTALLED' ? '\n• brain not installed on this machine.'
|
|
68
|
-
:
|
|
71
|
+
: report.verdict === 'PARTIAL'
|
|
72
|
+
? (color ? '\x1b[33m' : '') + `\n• partial — ${warnings} readiness warning${warnings === 1 ? '' : 's'} above (no drift)` + (color ? '\x1b[0m' : '')
|
|
73
|
+
: (color ? '\x1b[32m' : '') + '\n✓ aligned.' + (color ? '\x1b[0m' : ''));
|
|
69
74
|
process.exit(drifted ? 1 : 0);
|
|
70
75
|
} catch (e) {
|
|
71
76
|
console.error(`✗ doctor failed: ${e?.message || e}`);
|
package/bin/klypix-install.mjs
CHANGED
|
@@ -212,6 +212,33 @@ function renameSyncWithBackoff(from, to) {
|
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
// The live install as its own `.mcp-runtime.json` describes it, every listed
|
|
216
|
+
// file read and hashed now: { raw, entries: [[name, bytes]] }; { absent: true }
|
|
217
|
+
// with no manifest (an install from before the supervisor); { error } when a
|
|
218
|
+
// listed file is missing or differs — a half-applied install, two versions'
|
|
219
|
+
// files side by side. Only the flat layout (bare file names) is accepted.
|
|
220
|
+
function readVerifiedLiveRuntime(dir) {
|
|
221
|
+
const manifestPath = path.join(dir, '.mcp-runtime.json');
|
|
222
|
+
let raw;
|
|
223
|
+
try { raw = fs.readFileSync(manifestPath, 'utf8'); }
|
|
224
|
+
catch (e) { return e?.code === 'ENOENT' ? { absent: true } : { error: `manifest unreadable (${e?.code || e?.message})` }; }
|
|
225
|
+
let manifest;
|
|
226
|
+
try { manifest = JSON.parse(raw); } catch { return { error: 'manifest is not valid JSON' }; }
|
|
227
|
+
const files = manifest && typeof manifest.files === 'object' && !Array.isArray(manifest.files) ? manifest.files : null;
|
|
228
|
+
if (manifest?.protocol !== 1 || !files || !Object.prototype.hasOwnProperty.call(files, String(manifest.worker || ''))) {
|
|
229
|
+
return { error: 'manifest does not hash its worker' };
|
|
230
|
+
}
|
|
231
|
+
const entries = [];
|
|
232
|
+
for (const [name, expected] of Object.entries(files)) {
|
|
233
|
+
if (path.basename(name) !== name) return { error: `unexpected path ${name}` };
|
|
234
|
+
let bytes;
|
|
235
|
+
try { bytes = fs.readFileSync(path.join(dir, name)); } catch { return { error: `${name} is missing` }; }
|
|
236
|
+
if (crypto.createHash('sha256').update(bytes).digest('hex') !== expected) return { error: `${name} differs from its hash` };
|
|
237
|
+
entries.push([name, bytes]);
|
|
238
|
+
}
|
|
239
|
+
return { raw, entries };
|
|
240
|
+
}
|
|
241
|
+
|
|
215
242
|
function migrateProjectMcpConfig() {
|
|
216
243
|
try {
|
|
217
244
|
const file = path.join(process.cwd(), '.mcp.json');
|
|
@@ -416,10 +443,37 @@ try {
|
|
|
416
443
|
const s = path.join(BIN, src); if (exists(s)) staged.push({ dst, content: flatten(fs.readFileSync(s, 'utf8')) });
|
|
417
444
|
}
|
|
418
445
|
for (const st of staged) fs.writeFileSync(path.join(BRAIN_DIR, st.dst + '.klypix-new'), st.content);
|
|
446
|
+
// .prev is what a supervisor boots while an install is half-applied (a
|
|
447
|
+
// fresh connection, a wake, a crash recovery), so it must be ONE version,
|
|
448
|
+
// whole (K1-PREV-UNVERIFIED, 2026-10-03 review). It used to be refreshed from
|
|
449
|
+
// whatever the live directory held: a retry after an install that stopped
|
|
450
|
+
// mid-rename (the updater's own, 15 min later) copied the MIXED set over the
|
|
451
|
+
// last complete snapshot. Now the live files are snapshotted only when they
|
|
452
|
+
// verify against their own manifest — the very bytes that were hashed are
|
|
453
|
+
// written — and that manifest is copied in LAST: it is what the supervisor
|
|
454
|
+
// verifies before it boots .prev (prevSnapshotAt). A live directory that
|
|
455
|
+
// fails its manifest leaves the last complete snapshot where it is.
|
|
419
456
|
try {
|
|
420
|
-
const prevDir = path.join(BRAIN_DIR, '.prev');
|
|
421
|
-
|
|
422
|
-
|
|
457
|
+
const prevDir = path.join(BRAIN_DIR, '.prev');
|
|
458
|
+
const prevManifest = path.join(prevDir, '.mcp-runtime.json');
|
|
459
|
+
const live = readVerifiedLiveRuntime(BRAIN_DIR);
|
|
460
|
+
if (live.entries) {
|
|
461
|
+
fs.mkdirSync(prevDir, { recursive: true });
|
|
462
|
+
fs.rmSync(prevManifest, { force: true }); // nothing vouches for .prev while it changes
|
|
463
|
+
for (const [name, bytes] of live.entries) fs.writeFileSync(path.join(prevDir, name), bytes);
|
|
464
|
+
fs.writeFileSync(prevManifest + '.klypix-new', live.raw);
|
|
465
|
+
renameSyncWithBackoff(prevManifest + '.klypix-new', prevManifest);
|
|
466
|
+
} else if (live.absent) {
|
|
467
|
+
// No manifest to verify against (an install from before the
|
|
468
|
+
// supervisor): a plain copy for a manual rollback, which no
|
|
469
|
+
// supervisor boots — nothing vouches for it.
|
|
470
|
+
fs.mkdirSync(prevDir, { recursive: true });
|
|
471
|
+
fs.rmSync(prevManifest, { force: true });
|
|
472
|
+
for (const st of staged) { const file = path.join(BRAIN_DIR, st.dst); if (exists(file)) fs.copyFileSync(file, path.join(prevDir, st.dst)); }
|
|
473
|
+
} else {
|
|
474
|
+
console.log(`• .prev kept: the live core files do not match their manifest (${live.error}) — the last complete snapshot stays the rollback copy`);
|
|
475
|
+
}
|
|
476
|
+
} catch { /* .prev rollback snapshot is best-effort; an unfinished one has no manifest */ }
|
|
423
477
|
const renameOrder = staged.slice().sort((a, b) => (a.dst === 'global-brain-hook.mjs' ? 1 : 0) - (b.dst === 'global-brain-hook.mjs' ? 1 : 0));
|
|
424
478
|
let n = 0;
|
|
425
479
|
for (const st of renameOrder) { renameSyncWithBackoff(path.join(BRAIN_DIR, st.dst + '.klypix-new'), path.join(BRAIN_DIR, st.dst)); n++; }
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -32,14 +32,18 @@ import {
|
|
|
32
32
|
} from '../src/klypix-core.mjs';
|
|
33
33
|
import { compareProjectGraphResults, projectGraphContextMarkdown, queryProjectGraph, suggestProjectGraphBrainLinks, scanNativeProjectMap, checkBrainDrift, brainDriftMarkdown } from '../src/project-graph.mjs';
|
|
34
34
|
import { auditProject, compactAgentsBrief, linkProject, mcpServerEntry } from '../src/agent-rules.mjs';
|
|
35
|
-
import { createMcpPresence, KLYPIX_MCP_INSTRUCTIONS } from '../src/mcp-presence.mjs';
|
|
36
|
-
import { consumeMessageReceipt, findProjectBrain } from '../src/agent-presence.mjs';
|
|
35
|
+
import { createMcpPresence, KLYPIX_MCP_INSTRUCTIONS, normalizeMcpClient } from '../src/mcp-presence.mjs';
|
|
36
|
+
import { consumeMessageReceipt, findProjectBrain, listActiveSessions } from '../src/agent-presence.mjs';
|
|
37
37
|
import { collectRepoState, commitsInRange, makeContainmentProbe } from '../src/repo-state.mjs';
|
|
38
38
|
import {
|
|
39
39
|
reconcileRegisteredProjects,
|
|
40
40
|
registerProjectBrain,
|
|
41
41
|
spawnAutoUpdateHelper,
|
|
42
42
|
} from '../src/mcp-auto-update.mjs';
|
|
43
|
+
// Namespace import for constants added after 1.89.0 (AUTO_UPDATE_POLL_MS): a
|
|
44
|
+
// worker that meets an older mcp-auto-update.mjs mid-install must degrade to a
|
|
45
|
+
// fallback, not fail to link over a missing named export.
|
|
46
|
+
import * as autoUpdateModule from '../src/mcp-auto-update.mjs';
|
|
43
47
|
// Namespace import (already in-process via the klypix-core chain, so zero added
|
|
44
48
|
// load cost) so a bundle whose klypix-format predates classifyDecay degrades
|
|
45
49
|
// gracefully — a named import of a missing export would kill the whole server.
|
|
@@ -1130,7 +1134,7 @@ server.registerTool('brain_sync', {
|
|
|
1130
1134
|
|
|
1131
1135
|
server.registerTool('brain_doctor', {
|
|
1132
1136
|
title: 'Brain doctor — is this brain current, wired, and in sync?',
|
|
1133
|
-
description: 'Read-only self-check of the installed klypix brain, as ONE verdict: VERSION (deployed brain-core + optional npm currency), CLAUDE (existing 5-hook capture readiness), CODEX (automatic MCP presence plus optional enhanced-hook status), TOOLS (discoverable MCP verbs), SESSIONS (all active presence-adapter sessions across hosts, never recent-chat history), and HARNESS (projection drift). Use to answer "is my brain current, correctly installed, in sync, and who is actually live?" without file-spelunking. Never writes: the only side effects are read-only subprocess queries (git rev-parse / tag --list / log / merge-base with fixed argument arrays, and `npm view` only when check_npm is true) — it creates, edits, and deletes nothing. SCOPE: only CLAUDE and CODEX get behavioural verdicts. HARNESS classifies the projected config/rules FILES on disk — a project can read fully ok while no other host has ever actually loaded them, so do not report a clean HARNESS as "Cursor/Cline/Windsurf/Copilot is working". The MCP-callable twin of `npx klypix-mcp doctor`.',
|
|
1137
|
+
description: 'Read-only self-check of the installed klypix brain, as ONE verdict: VERSION (deployed brain-core + optional npm currency), CLAUDE (existing 5-hook capture readiness), CODEX (automatic MCP presence plus optional enhanced-hook status), TOOLS (discoverable MCP verbs), SESSIONS (all active presence-adapter sessions across hosts, never recent-chat history), and HARNESS (projection drift). Use to answer "is my brain current, correctly installed, in sync, and who is actually live?" without file-spelunking. Never writes: the only side effects are read-only subprocess queries (git rev-parse / tag --list / log / merge-base with fixed argument arrays, and `npm view` only when check_npm is true) — it creates, edits, and deletes nothing. SCOPE: only CLAUDE and CODEX get behavioural verdicts. HARNESS classifies the projected config/rules FILES on disk — a project can read fully ok while no other host has ever actually loaded them, so do not report a clean HARNESS as "Cursor/Cline/Windsurf/Copilot is working". The MCP-callable twin of `npx klypix-mcp doctor`. Besides the text, the result carries structuredContent {verdict, layers, version, autoUpdate, supervisors, readinessWarnings, actions}: the same verdict as data, including when the next automatic update check runs and what it will do.',
|
|
1134
1138
|
inputSchema: {
|
|
1135
1139
|
project: z.string().optional().describe('Project dir to audit harness + peers for. Defaults to the server\'s working directory.'),
|
|
1136
1140
|
check_npm: z.boolean().optional().describe('Also fetch npm latest to flag a stale brain (default false — this one does a network `npm view`).'),
|
|
@@ -1139,7 +1143,7 @@ server.registerTool('brain_doctor', {
|
|
|
1139
1143
|
try {
|
|
1140
1144
|
// Lazy import so a flat runtime missing brain-doctor.mjs can't crash server STARTUP —
|
|
1141
1145
|
// the tool degrades gracefully (errors only when called) instead of taking the server down.
|
|
1142
|
-
const { inspect, render } = await import('../src/brain-doctor.mjs');
|
|
1146
|
+
const { inspect, render, structuredReport } = await import('../src/brain-doctor.mjs');
|
|
1143
1147
|
let npmLatest = null;
|
|
1144
1148
|
if (check_npm) {
|
|
1145
1149
|
try { const { execSync } = await import('child_process'); npmLatest = execSync('npm view klypix-mcp version', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 8000 }).trim(); }
|
|
@@ -1153,7 +1157,17 @@ server.registerTool('brain_doctor', {
|
|
|
1153
1157
|
npmLatest,
|
|
1154
1158
|
self: { pid: process.pid, version: PKG_VERSION, id: mcpPresence.id },
|
|
1155
1159
|
});
|
|
1156
|
-
|
|
1160
|
+
// E1 (2026-10-03): the same verdict as data next to the unchanged text — the
|
|
1161
|
+
// layers, the auto-update schedule and each connection's state, without
|
|
1162
|
+
// parsing rendered lines. A brain-doctor.mjs that predates the projection
|
|
1163
|
+
// (an install that stopped half-way) leaves it out; the text still answers.
|
|
1164
|
+
let structuredContent = null;
|
|
1165
|
+
try { if (typeof structuredReport === 'function') structuredContent = structuredReport(report); }
|
|
1166
|
+
catch { structuredContent = null; }
|
|
1167
|
+
return {
|
|
1168
|
+
content: [{ type: 'text', text: render(report, { color: false }) }],
|
|
1169
|
+
...(structuredContent ? { structuredContent } : {}),
|
|
1170
|
+
};
|
|
1157
1171
|
} catch (e) {
|
|
1158
1172
|
return { content: [{ type: 'text', text: `brain_doctor unavailable: ${e?.message || e}` }], isError: true };
|
|
1159
1173
|
}
|
|
@@ -1269,6 +1283,44 @@ if (!canvasViewAsApp) {
|
|
|
1269
1283
|
}, canvasViewHandler);
|
|
1270
1284
|
}
|
|
1271
1285
|
|
|
1286
|
+
// ── Supervisor hibernation probe (2026-10-03) ────────────────────────────────
|
|
1287
|
+
// Before it retires an idle worker, the supervisor asks which lane row this
|
|
1288
|
+
// connection owns, so it can keep that row fresh while the worker sleeps. It
|
|
1289
|
+
// used to ask with an internal brain_sync checkpoint, which the lane records as
|
|
1290
|
+
// McpTaskCheckpoint — WORK — so every hibernation made an idle connection look
|
|
1291
|
+
// busy (6 idle Codex connections on the founder's PC read as active sessions
|
|
1292
|
+
// with no declared scope). This internal request is not a tool: hosts never see
|
|
1293
|
+
// it, and it reads the binding and the lane row without writing anything.
|
|
1294
|
+
// Supervisors from before it never send it; a worker from before it answers
|
|
1295
|
+
// "Method not found", and the supervisor falls back to the checkpoint.
|
|
1296
|
+
const SUPERVISOR_IDENTITY_METHOD = 'klypix/presenceIdentity';
|
|
1297
|
+
server.server.setRequestHandler(z.object({
|
|
1298
|
+
method: z.literal(SUPERVISOR_IDENTITY_METHOD),
|
|
1299
|
+
params: z.unknown().optional(),
|
|
1300
|
+
}), () => {
|
|
1301
|
+
const brainPath = mcpPresence.brainPath;
|
|
1302
|
+
if (!brainPath) return { schemaVersion: 1, reason: 'no-project-brain', brain: null, self: null };
|
|
1303
|
+
const id = String(mcpPresence.id || '');
|
|
1304
|
+
let row = null;
|
|
1305
|
+
try { row = listActiveSessions({ brainPath }).find((session) => session.id === id) || null; }
|
|
1306
|
+
catch { /* unreadable lane: the binding alone still names the row */ }
|
|
1307
|
+
// No row (pruned, or a lane write that never landed): describe the client the
|
|
1308
|
+
// way the worker's own heartbeat would, so the supervisor's upsert recreates
|
|
1309
|
+
// an accurate row instead of an 'unknown' one.
|
|
1310
|
+
let clientName = '';
|
|
1311
|
+
try { clientName = String(server.server.getClientVersion?.()?.name || ''); } catch { /* optional */ }
|
|
1312
|
+
return {
|
|
1313
|
+
schemaVersion: 1,
|
|
1314
|
+
brain: brainPath,
|
|
1315
|
+
self: id ? {
|
|
1316
|
+
id,
|
|
1317
|
+
client: row?.client || normalizeMcpClient(clientName),
|
|
1318
|
+
surface: row?.surface ?? (clientName.replace(/\s+/g, ' ').trim() || 'mcp'),
|
|
1319
|
+
branch: row?.branch ?? null,
|
|
1320
|
+
} : null,
|
|
1321
|
+
};
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1272
1324
|
const transport = new StdioServerTransport();
|
|
1273
1325
|
let runningHeartbeat = null;
|
|
1274
1326
|
let autoUpdateStarter = null;
|
|
@@ -1318,7 +1370,7 @@ server.server.oninitialized = () => {
|
|
|
1318
1370
|
});
|
|
1319
1371
|
autoUpdateStarter = setTimeout(checkForCoreUpdate, 2000);
|
|
1320
1372
|
autoUpdateStarter.unref?.();
|
|
1321
|
-
autoUpdatePoller = setInterval(checkForCoreUpdate, 60 * 60 * 1000);
|
|
1373
|
+
autoUpdatePoller = setInterval(checkForCoreUpdate, Math.max(60_000, Number(autoUpdateModule.AUTO_UPDATE_POLL_MS) || 60 * 60 * 1000));
|
|
1322
1374
|
autoUpdatePoller.unref?.();
|
|
1323
1375
|
log(`ready · vault=${VAULT} · presence=mcp`);
|
|
1324
1376
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.90.1",
|
|
4
4
|
"mcpName": "io.github.dahshanlabs/klypix-mcp",
|
|
5
5
|
"description": "Active state management for multi-agent coding: a shared, versioned project brain over MCP.",
|
|
6
6
|
"type": "module",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"bench": "node bin/klypix-mcp.mjs bench",
|
|
85
85
|
"test:bench": "node test/bench.mjs",
|
|
86
86
|
"pretest": "node test/publish-workflow.mjs",
|
|
87
|
-
"test": "node test/publish-verdict.mjs && node test/npx-owned-names.mjs && 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/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/session-mailbox.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.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/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.mjs && node test/plan-fulfillment.mjs && node test/arrange-receipts.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/provenance.mjs && node test/confirm-trail.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/hook-unified-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-reconcile.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/revival-map.mjs && node test/merge-scale.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/canvas-groups.mjs && node test/git-tools.mjs && node test/link-compat.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/status-shape.mjs && node test/status-hook.mjs",
|
|
87
|
+
"test": "node test/publish-verdict.mjs && node test/npx-owned-names.mjs && 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/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/session-mailbox.mjs && node test/lane-write-retry.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.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/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.mjs && node test/plan-fulfillment.mjs && node test/arrange-receipts.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/provenance.mjs && node test/confirm-trail.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/hook-unified-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-reconcile.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/revival-map.mjs && node test/merge-scale.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/canvas-groups.mjs && node test/git-tools.mjs && node test/link-compat.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/status-shape.mjs && node test/status-hook.mjs",
|
|
88
88
|
"test:memory": "node test/memory-runtime.mjs",
|
|
89
89
|
"test:memory:soak": "node --expose-gc test/memory-soak.mjs",
|
|
90
90
|
"runtime": "node bin/klypix-runtime.mjs",
|
package/src/agent-presence.mjs
CHANGED
|
@@ -373,21 +373,51 @@ export function looksMachineTurn(text) {
|
|
|
373
373
|
// lines) can never parse a torn lane as an authoritative "0 peers / no
|
|
374
374
|
// messages", and a crash mid-write can never destroy undelivered messages.
|
|
375
375
|
// On Windows, renaming over a destination a reader/AV momentarily holds open
|
|
376
|
-
// throws EPERM
|
|
377
|
-
//
|
|
378
|
-
//
|
|
376
|
+
// throws EPERM/EACCES/EBUSY. ONE immediate retry used to be the whole answer,
|
|
377
|
+
// and it stopped being enough once many sessions shared a lane: 16 lost races
|
|
378
|
+
// in six hours at 15–23 live sessions (2026-10-02), and a lost race is not
|
|
379
|
+
// cosmetic — the throw left an MCP tool call failed outright (a brain_note)
|
|
380
|
+
// and a hook heartbeat or delivery dropped. The rename now backs off like the
|
|
381
|
+
// brain's own atomic write does; worst case ~185 ms, inside the caller's lane
|
|
382
|
+
// lock (peers' lock budget is 500–600 ms, so one slow writer delays them, it
|
|
383
|
+
// does not starve them). On final failure the tmp is REMOVED before
|
|
384
|
+
// rethrowing (the field found dozens of orphaned `.tmp-<pid>-<rand>` files
|
|
385
|
+
// littering the sessions dir, 2026-08-07).
|
|
386
|
+
// PARITY: global-brain-hook.mjs renameRetry — same codes, same backoff.
|
|
387
|
+
const RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
388
|
+
export const LANE_RENAME_BACKOFF_MS = Object.freeze([10, 25, 50, 100]);
|
|
389
|
+
// Returns the number of attempts it took. `rename` and `sleep` are injectable
|
|
390
|
+
// for tests; the defaults are read at CALL time so a patched fs is honoured.
|
|
391
|
+
export function renameWithRetry(from, to, { rename = fs.renameSync, sleep = sleepSync, backoffMs = LANE_RENAME_BACKOFF_MS } = {}) {
|
|
392
|
+
for (let attempt = 0; ; attempt++) {
|
|
393
|
+
try {
|
|
394
|
+
rename(from, to);
|
|
395
|
+
return attempt + 1;
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (!RENAME_RETRY_CODES.has(error?.code) || attempt >= backoffMs.length) throw error;
|
|
398
|
+
sleep(backoffMs[attempt]);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
379
403
|
function writeLaneFileAtomic(laneFile, payload) {
|
|
380
404
|
const tmp = `${laneFile}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
381
405
|
fs.writeFileSync(tmp, payload);
|
|
382
406
|
try {
|
|
383
|
-
|
|
407
|
+
renameWithRetry(tmp, laneFile);
|
|
384
408
|
} catch (err) {
|
|
385
|
-
try { fs.
|
|
386
|
-
|
|
409
|
+
try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
|
|
410
|
+
throw err;
|
|
387
411
|
}
|
|
388
412
|
sweepStaleTmpFiles(path.dirname(laneFile));
|
|
389
413
|
}
|
|
390
414
|
|
|
415
|
+
// A lane write that still fails after the backoff is reported, never thrown
|
|
416
|
+
// out of a heartbeat or a send: the caller gets a verdict it can show ("the
|
|
417
|
+
// note was not posted, retry") instead of an exception that fails a whole tool
|
|
418
|
+
// call for a reason unrelated to what the tool was asked to do.
|
|
419
|
+
const writeFailureReason = (error) => `write-failed:${String(error?.code || error?.message || 'unknown').slice(0, 80)}`;
|
|
420
|
+
|
|
391
421
|
// Opportunistic janitor for tmp orphans left by crashes or the pre-fix rename
|
|
392
422
|
// path. Throttled to once per process per 10 minutes; only files matching our
|
|
393
423
|
// own tmp naming and older than 15 minutes are touched, so an in-flight write
|
|
@@ -1097,14 +1127,20 @@ export function upsertSession({
|
|
|
1097
1127
|
};
|
|
1098
1128
|
const kept = sessions.filter((session) => session.id !== id);
|
|
1099
1129
|
kept.push(next);
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1130
|
+
try {
|
|
1131
|
+
fs.mkdirSync(path.dirname(laneFile), { recursive: true });
|
|
1132
|
+
writeLaneFileAtomic(laneFile, JSON.stringify({
|
|
1133
|
+
...data,
|
|
1134
|
+
sessions: kept.slice(-40),
|
|
1135
|
+
messages: maintainMessages(data.messages, now),
|
|
1136
|
+
endedSessions,
|
|
1137
|
+
directory: rememberSession(data.directory, next, now),
|
|
1138
|
+
}));
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
// The heartbeat did not land: return the lane as it IS (without this
|
|
1141
|
+
// touch) and say so, exactly like the lock-timeout path above.
|
|
1142
|
+
return withWriteVerdict(sessions.sort((a, b) => Number(b.lastSeen || 0) - Number(a.lastSeen || 0)), false, writeFailureReason(error));
|
|
1143
|
+
}
|
|
1108
1144
|
return withWriteVerdict(kept.sort((a, b) => Number(b.lastSeen || 0) - Number(a.lastSeen || 0)), true);
|
|
1109
1145
|
} finally {
|
|
1110
1146
|
if (gotLock) releaseLock(lockFile);
|
|
@@ -2694,12 +2730,19 @@ export function postPresenceMessage({
|
|
|
2694
2730
|
...(offline ? { offline } : {}),
|
|
2695
2731
|
};
|
|
2696
2732
|
messages.push(message);
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2733
|
+
try {
|
|
2734
|
+
fs.mkdirSync(path.dirname(laneFile), { recursive: true });
|
|
2735
|
+
writeLaneFileAtomic(laneFile, JSON.stringify({
|
|
2736
|
+
...data,
|
|
2737
|
+
sessions,
|
|
2738
|
+
messages: capMessages(messages, MESSAGE_LANE_CAP, now),
|
|
2739
|
+
}));
|
|
2740
|
+
} catch (error) {
|
|
2741
|
+
// Nothing was written, so nothing was posted: tell the sender to retry
|
|
2742
|
+
// rather than throwing — a thrown lane error reads to a model as "the
|
|
2743
|
+
// tool is broken" and sends it back to asking the human to relay.
|
|
2744
|
+
return { posted: false, message: null, reason: writeFailureReason(error) };
|
|
2745
|
+
}
|
|
2703
2746
|
// Who will actually see this, and in what state — the sender's reply to the
|
|
2704
2747
|
// human is built from this, so it must say "idle 14m" or "not running",
|
|
2705
2748
|
// never just "queued".
|