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 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, notes expire after 24 hours, and they are never written
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 blocked result claim is kept in a durable per-project/session marker, so a
754
- worker replacement cannot turn a failed evidence check into a result-less completion.
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
- `brain_doctor` reports the live supervisor and the automatic-update receipt explicitly.
760
-
761
- The supervisor performs **one machine-wide npm version check per 24 hours**, however many sessions
762
- are open. It installs an exact stable same-major release in `--runtime-only` mode, preserving host
763
- settings and project files. The check is detached and fail-open, developer-owned installs are
764
- protected, concurrent sessions collapse behind one lock, and `KLYPIX_AUTO_UPDATE=0` opts out
765
- entirely.
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 one exception in this package
778
- is the supervisor's once-per-24h npm version check described above — turn it off with
779
- `KLYPIX_AUTO_UPDATE=0`.
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 24-hour auto-updater generates
873
- most of it, so it is not a user count. No adoption, team or customer figures. No brief-token
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.
@@ -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
- : (color ? '\x1b[32m' : '') + '\n✓ aligned.' + (color ? '\x1b[0m' : ''));
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}`);
@@ -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'); fs.mkdirSync(prevDir, { recursive: true });
421
- for (const st of staged) { const live = path.join(BRAIN_DIR, st.dst); if (exists(live)) fs.copyFileSync(live, path.join(prevDir, st.dst)); }
422
- } catch { /* .prev rollback snapshot is best-effort */ }
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++; }
@@ -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
- return { content: [{ type: 'text', text: render(report, { color: false }) }] };
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.89.0",
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",
@@ -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 — one immediate retry wins that race, and on final failure the
377
- // tmp is REMOVED before rethrowing (the field found dozens of orphaned
378
- // `.tmp-<pid>-<rand>` files littering the sessions dir, 2026-08-07).
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
- fs.renameSync(tmp, laneFile);
407
+ renameWithRetry(tmp, laneFile);
384
408
  } catch (err) {
385
- try { fs.renameSync(tmp, laneFile); }
386
- catch { try { fs.unlinkSync(tmp); } catch { /* best-effort */ } throw err; }
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
- fs.mkdirSync(path.dirname(laneFile), { recursive: true });
1101
- writeLaneFileAtomic(laneFile, JSON.stringify({
1102
- ...data,
1103
- sessions: kept.slice(-40),
1104
- messages: maintainMessages(data.messages, now),
1105
- endedSessions,
1106
- directory: rememberSession(data.directory, next, now),
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
- fs.mkdirSync(path.dirname(laneFile), { recursive: true });
2698
- writeLaneFileAtomic(laneFile, JSON.stringify({
2699
- ...data,
2700
- sessions,
2701
- messages: capMessages(messages, MESSAGE_LANE_CAP, now),
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".