klypix-mcp 1.90.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.
@@ -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++; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.90.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",
@@ -297,9 +297,6 @@ function inspectRunning(brainDir, baked, now, self) {
297
297
  const SUPERVISOR_DEAD_RECEIPT_MS = 120 * 1000;
298
298
  const SUPERVISOR_TRANSITION_MS = 45 * 1000;
299
299
  const WAKING_STATUSES = new Set(['validating-update', 'update-ready', 'recovering', 'recovery-ready']);
300
- // readRuntimeTarget's failures (mcp-supervisor.mjs): the core files on disk do
301
- // not verify, so a sleeping pair has nothing consistent to wake into.
302
- const RUNTIME_INTEGRITY_ERROR = /^(runtime (manifest|worker|file|integrity)\b|unsupported runtime protocol)/;
303
300
  const majorOf = (version) => {
304
301
  const match = String(version || '').match(/^v?(\d+)\.\d+\.\d+/);
305
302
  return match ? Number(match[1]) : null;
@@ -357,18 +354,26 @@ function inspectSupervisors(brainDir, baked, now = Date.now()) {
357
354
  // queue. A broken or backpressured host pipe stays what it is.
358
355
  const deliveryStatus = transition === 'waking' && !['impaired', 'backpressured'].includes(state.transport?.host)
359
356
  ? 'queued' : recordedDelivery;
360
- // F6 (2026-10-03 review): a sleeping pair whose last wake found the core
361
- // files failing verification (wakeDeferred), or whose poller has reported
362
- // them failing for longer than an install takes, answers every request with
363
- // an error until they verify. It used to read "wakes on the next request".
357
+ // F6 (2026-10-03 review): a sleeping pair whose last wake found no
358
+ // consistent core to boot (wakeDeferred, which the supervisor writes on the
359
+ // first refused wake) answers every request with an error until the core
360
+ // files verify. It used to read "wakes on the next request".
361
+ // Only that refusal counts (K1-DOCTOR-WAKEBLOCKED-FALSE, 2026-10-03 review),
362
+ // the rule `klypix-mcp runtime` follows (runtime-inspector wakeBlock). An
363
+ // integrity error the receipt merely carries is not one: the wake resumes
364
+ // .prev when it holds this pair's version — every pair K1 booted from .prev
365
+ // records the error in its first receipt and wakes from .prev — or the
366
+ // package's own worker outside the managed directory. Read as "cannot
367
+ // wake", every connection that started during a failing install was
368
+ // printed IMPAIRED while it woke without trouble. The error itself is still
369
+ // shown on the pair's line.
364
370
  const wakeDeferred = intentionallyHibernated && state.hibernation?.wakeDeferred
365
371
  && typeof state.hibernation.wakeDeferred === 'object' ? state.hibernation.wakeDeferred : null;
366
- // A pair that already served from .prev can resume that same snapshot;
367
- // the live directory's integrity error alone does not prove its wake fails.
368
- // An actual refused wake still wins over this last-known fallback.
369
- const hasPreviousWorker = sleepingTarget?.source === 'rollback';
370
- const wakeBlocked = intentionallyHibernated
371
- && (Boolean(wakeDeferred) || (!hasPreviousWorker && RUNTIME_INTEGRITY_ERROR.test(String(state.lastError || '')) && !fresh));
372
+ // Only a wake the supervisor actually refused proves a sleeping pair cannot
373
+ // wake — the same rule as `klypix-mcp runtime` (wakeBlock). An integrity error
374
+ // alone does not: the wake waits for the install to settle, and a pair that
375
+ // served from .prev resumes that snapshot while it still verifies.
376
+ const wakeBlocked = intentionallyHibernated && Boolean(wakeDeferred);
372
377
  const workerImpaired = (!state.active && !intentionallyHibernated && transition !== 'waking'
373
378
  && status !== 'starting' && status !== 'awaiting-initialize') || wakeBlocked;
374
379
  const deliveryImpaired = deliveryStatus === 'impaired' || state.transport?.host === 'impaired';
@@ -634,6 +639,20 @@ function autoUpdateView({ au, brainDir, supervisors, version, hooks, npmLatest,
634
639
  const overdueSuppressed = rule.suppressed === 'no-live-session' ? 'no-live-session'
635
640
  : (skew && (rule.overdue || rule.suppressed) ? 'version-skew' : (rule.suppressed || null));
636
641
  const overdue = rule.overdue === true && !skew;
642
+ // TR-1 (2026-10-03 review): a long-due check the rule did NOT judge for want
643
+ // of a poll (K3) used to print the generic "check due now — runs within
644
+ // 10 min", hiding how long it had been due and repeating a promise an open
645
+ // session had visibly not kept. That is the normal state right after this
646
+ // release: every live connection runs pre-fix supervisor code, which records
647
+ // no poll, until it reconnects. Keep not judging, but carry what is known:
648
+ // how long it has been due, the latest poll on record, and how many sessions
649
+ // record none.
650
+ const pollers = live.filter((state) => state.autoUpdateEnabled !== false);
651
+ const pollTimes = pollers.map((state) => timeOf(state.lastPollAt)).filter((ms) => Number.isFinite(ms) && ms <= now + 1000);
652
+ const latestPollAt = pollTimes.length ? new Date(Math.max(...pollTimes)).toISOString() : null;
653
+ // Pre-fix supervisor code (no supervisorVersion, B9) never records a poll; a
654
+ // fixed one records its first 2 s after it starts, so it is not counted here.
655
+ const unpolledSessions = pollers.filter((state) => state.preFix && !Number.isFinite(timeOf(state.lastPollAt))).length;
637
656
 
638
657
  const knownLatest = knownNpmLatest(brainDir, au, npmLatest, now);
639
658
  // MV-2: a pre-hold updater re-installs whatever the owner rolled back from;
@@ -679,6 +698,11 @@ function autoUpdateView({ au, brainDir, supervisors, version, hooks, npmLatest,
679
698
  overdue,
680
699
  overdueByMs: overdue ? rule.overdueByMs : null,
681
700
  overdueSuppressed,
701
+ // How long the check has been due by the rule's own reckoning (null when
702
+ // it was due since before any record), and the polls behind a suppression.
703
+ dueForMs: Number.isFinite(rule.dueForMs) ? rule.dueForMs : null,
704
+ latestPollAt,
705
+ unpolledSessions,
682
706
  // What the overdue rule saw: the live sessions that polled after the check
683
707
  // fell due, and the latest such poll (K3).
684
708
  overdueEvidence: overdue ? rule.evidence || null : null,
@@ -1633,6 +1657,18 @@ export function render(r, opts = {}) {
1633
1657
  parts.push(`next check unknown${au.scheduleError ? ` (${au.scheduleError})` : ''}`);
1634
1658
  } else if (au.overdue) {
1635
1659
  parts.push(`${c.yel}check overdue by ${durationText(au.overdueByMs)} — no running session performed the check${c.rst}`);
1660
+ } else if (au.overdueSuppressed === 'no-poll-evidence' && Number.isFinite(au.dueForMs)) {
1661
+ // TR-1: not judged (K3), but not "due now" either — the rule reports
1662
+ // this only once the check is past its 30 min grace. Say how long, and
1663
+ // why it is not called overdue.
1664
+ const sinceMs = nowMs - au.dueForMs;
1665
+ const polledMs = timeOf(au.latestPollAt);
1666
+ const why = Number.isFinite(polledMs) && polledMs >= sinceMs
1667
+ ? `the latest poll on record (${isoMinute(polledMs)}, ${durationText(nowMs - polledMs)} ago) is too recent, or too close to the due time, to judge it overdue yet`
1668
+ : (au.unpolledSessions
1669
+ ? `overdue is not judged: no open session has recorded a poll since then — ${au.unpolledSessions} connection${au.unpolledSessions === 1 ? '' : 's'} on pre-fix supervisor code record${au.unpolledSessions === 1 ? 's' : ''} none (/mcp reconnect)`
1670
+ : `no open session has polled since then; it runs within ${pollText} while one is open`);
1671
+ parts.push(`check due since ${isoMinute(sinceMs)} (${durationText(au.dueForMs)}) — ${why}`);
1636
1672
  } else if (dueMs <= nowMs) {
1637
1673
  parts.push(`check due now — runs within ${pollText} while any KLYPIX session is open, or 2 s after the next one starts`);
1638
1674
  } else {
@@ -5474,6 +5474,13 @@ function updateRemedy({ plan, decision, overdue = null, latest, baked, now, spaw
5474
5474
  }
5475
5475
  return { mark: '⚠️', text: `Automatic check overdue (${late}) — run ${installedDoctorHint(baked)}.` };
5476
5476
  }
5477
+ // TR-1 (2026-10-03 review): a check past its grace that the rule did not
5478
+ // judge for want of a poll (K3: every session on pre-fix supervisor code,
5479
+ // or none polled since it fell due) has been due for a while — "(due now)"
5480
+ // hid that. Not judged overdue, so still no alarm.
5481
+ const when = dueAt <= now && overdue && overdue.suppressed === 'no-poll-evidence' && Number.isFinite(overdue.dueForMs)
5482
+ ? `due for ${spanLabel(overdue.dueForMs)}`
5483
+ : etaLabel(dueAt, now);
5477
5484
  // A failed result written by a pre-2026-10-03 updater has no count in its
5478
5485
  // stamp; it is still one failed attempt, never a clean slate.
5479
5486
  const failures = Math.max(
@@ -5486,9 +5493,9 @@ function updateRemedy({ plan, decision, overdue = null, latest, baked, now, spaw
5486
5493
  const why = plan.result === 'failed' && plan.error
5487
5494
  ? String(plan.error).replace(/[`\r\n]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120)
5488
5495
  : 'it stopped before recording a result';
5489
- return { mark: '⚠️', text: `KLYPIX will retry automatically — last automatic attempt failed (${why}, attempt ${failures}); next retry ${etaLabel(dueAt, now)}.` };
5496
+ return { mark: '⚠️', text: `KLYPIX will retry automatically — last automatic attempt failed (${why}, attempt ${failures}); next retry ${when}.` };
5490
5497
  }
5491
- return promise(etaLabel(dueAt, now));
5498
+ return promise(when);
5492
5499
  }
5493
5500
 
5494
5501
  // SessionStart footer — ambient version drift. Reads ONLY local files (zero
@@ -61,6 +61,11 @@ const DEFAULT_AUTO_UPDATE_START_DELAY_MS = 2000;
61
61
  const RECOVERY_MAX_ATTEMPTS = 5;
62
62
  const RECOVERY_BACKOFF_BASE_MS = 1000;
63
63
  const RECOVERY_BACKOFF_MAX_MS = 60_000;
64
+ // Hot-swap retry policy (2026-10-03): a candidate that fails TRANSIENTLY while
65
+ // the old worker keeps serving is retried after base × 1, 4, 20 (30 s, 2 min,
66
+ // 10 min by default); only then is it kept rejected until a reconnect.
67
+ const DEFAULT_SWAP_RETRY_BASE_MS = 30_000;
68
+ const SWAP_RETRY_FACTORS = [1, 4, 20];
64
69
  // Unbounded queue growth is its own failure mode while a recovery is running.
65
70
  const HOST_QUEUE_MAX = 200;
66
71
  // A wake that finds the manifest failing integrity is usually racing an install:
@@ -166,17 +171,32 @@ const workerFileVersion = (file) => {
166
171
  return pkg?.name === 'klypix-mcp' && typeof pkg.version === 'string' ? pkg.version : null;
167
172
  } catch { return null; }
168
173
  };
169
- // The installer's snapshot of a worker file: `.prev/<name>` beside it. Every
170
- // live file is copied there before the first new one is renamed in, so it is a
171
- // complete copy of the previous install. Null when there is none, or when its
172
- // version is not baked in (only the flat bundle bakes it).
173
- const prevSnapshotTarget = (workerPath) => {
174
- const previousPath = path.join(path.dirname(workerPath), '.prev', path.basename(workerPath));
175
- if (!fs.existsSync(previousPath)) return null;
176
- const version = readBakedVersion(previousPath);
177
- if (!version) return null;
178
- return { path: previousPath, version, signature: `previous:${previousPath}:${version}`, source: 'rollback', dev: false };
174
+ // The installer's snapshot of the previous install, `.prev/` beside the worker,
175
+ // as a boot target — or null. It is one only while `.prev/.mcp-runtime.json`
176
+ // verifies here and now, byte for byte: the installer snapshots the live
177
+ // directory only when it verifies against its own manifest, and copies that
178
+ // manifest in LAST (bin/klypix-install.mjs). A baked version string used to be
179
+ // the whole check (K1-PREV-UNVERIFIED, 2026-10-03 review), and both installers
180
+ // refreshed .prev from whatever the live directory held — so a retry after a
181
+ // half-applied install filled .prev with two versions' files, and a boot or a
182
+ // wake from it loaded a mixed module graph. A .prev that no manifest vouches for
183
+ // (the desktop installer's, or any installer's before this) is never booted.
184
+ const prevSnapshotAt = (prevDir) => {
185
+ const manifestPath = path.join(prevDir, '.mcp-runtime.json');
186
+ const runtime = readRuntimeTarget(manifestPath);
187
+ if (!runtime.ok) return null;
188
+ const { path: file, version } = runtime.target;
189
+ // The worker itself must be among the files the manifest hashes, and carry
190
+ // the version the manifest names.
191
+ let hashed = false;
192
+ try {
193
+ const files = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))?.files;
194
+ hashed = isRecord(files) && Object.keys(files).some((relative) => path.resolve(prevDir, relative) === file);
195
+ } catch { /* unreadable since the verified read: not vouched for */ }
196
+ if (!hashed || readBakedVersion(file) !== version) return null;
197
+ return { path: file, version, signature: `previous:${file}:${version}`, source: 'rollback', dev: false };
179
198
  };
199
+ const prevSnapshotTarget = (workerPath) => prevSnapshotAt(path.join(path.dirname(workerPath), '.prev'));
180
200
 
181
201
  function createLineReader(onMessage, onError) {
182
202
  let buffered = Buffer.alloc(0);
@@ -334,6 +354,7 @@ function createRuntimeWatch(manifestPath, {
334
354
  now = () => Date.now(),
335
355
  } = {}) {
336
356
  let verified = null;
357
+ let lastKey = null;
337
358
  const statKey = () => {
338
359
  try {
339
360
  // bigint: NTFS file ids exceed 2^53 and must compare exactly.
@@ -344,6 +365,7 @@ function createRuntimeWatch(manifestPath, {
344
365
  return {
345
366
  read({ force = false } = {}) {
346
367
  const key = statKey();
368
+ lastKey = key;
347
369
  const at = now();
348
370
  if (!force && key !== null && verified?.key === key && at >= verified.at && at - verified.at < reverifyMs) {
349
371
  return { ok: true, target: verified.target, cached: true };
@@ -352,9 +374,42 @@ function createRuntimeWatch(manifestPath, {
352
374
  verified = runtime.ok && key !== null ? { key, at, target: runtime.target } : null;
353
375
  return runtime;
354
376
  },
377
+ // The manifest's stat now, and as it was just before the last read: a
378
+ // commit since that read moves it (settleRuntime).
379
+ statKey,
380
+ lastReadKey: () => lastKey,
355
381
  };
356
382
  }
357
383
 
384
+ // Wait for an install to settle (B3 wakes, K1 boots): repeat the read while it
385
+ // fails integrity (or cannot be read) for up to waitMs. Installs commit the
386
+ // manifest LAST, by rename-over, so until its stat moves nothing can have made
387
+ // a failing read verify: the wait polls the stat and re-hashes the runtime
388
+ // only when it moves, plus once at the deadline, before .prev or the fallback
389
+ // is chosen (K1-WAIT-FULL-REHASH, 2026-10-03 review). It used to re-hash every
390
+ // file every 250 ms — ~20 full reads (~8 MiB/s) per waiting connection, the
391
+ // load B8 removed from the poller because it contends with the installer's
392
+ // renames, whose EPERM is what leaves an install half-applied in the first place.
393
+ async function settleRuntime(watch, {
394
+ initial = watch.read({ force: true }),
395
+ waitMs = WAKE_INTEGRITY_WAIT_MS,
396
+ pollMs = WAKE_INTEGRITY_POLL_MS,
397
+ stopped = () => false,
398
+ } = {}) {
399
+ let runtime = initial;
400
+ const deadline = Date.now() + waitMs;
401
+ // The key taken just before that read: a commit landing during it still moves.
402
+ let seen = watch.lastReadKey();
403
+ while (!runtime.ok && !runtime.absent && !stopped() && Date.now() < deadline) {
404
+ await sleep(pollMs);
405
+ const key = watch.statKey();
406
+ if (key === seen && Date.now() < deadline) continue;
407
+ seen = key;
408
+ runtime = watch.read({ force: true });
409
+ }
410
+ return runtime;
411
+ }
412
+
358
413
  // Is this supervisor receipt provably dead? The boot cleanup deletes on a yes,
359
414
  // so every doubt keeps the receipt.
360
415
  function deadSupervisorReceipt(state, { now = Date.now(), probe = pidState } = {}) {
@@ -440,6 +495,10 @@ class Supervisor {
440
495
  this.pollMs = Number(options.pollMs || process.env.KLYPIX_MCP_SUPERVISOR_POLL_MS || DEFAULT_POLL_MS);
441
496
  this.timeoutMs = Number(options.timeoutMs || process.env.KLYPIX_MCP_SUPERVISOR_TIMEOUT_MS || DEFAULT_TIMEOUT_MS);
442
497
  this.rollbackGraceMs = Number(options.rollbackGraceMs || process.env.KLYPIX_MCP_ROLLBACK_GRACE_MS || DEFAULT_ROLLBACK_GRACE_MS);
498
+ this.swapRetryBaseMs = Math.max(1000, Number(options.swapRetryBaseMs || process.env.KLYPIX_MCP_SWAP_RETRY_BASE_MS || DEFAULT_SWAP_RETRY_BASE_MS) || DEFAULT_SWAP_RETRY_BASE_MS);
499
+ this.swapRetryAttempts = 0;
500
+ this.swapRetrySignature = null;
501
+ this.swapRetryTimer = null;
443
502
  this.autoUpdate = options.autoUpdate !== false && autoUpdateEnabled();
444
503
  this.autoUpdatePollMs = Number(
445
504
  options.autoUpdatePollMs
@@ -756,12 +815,11 @@ class Supervisor {
756
815
  return target.dev || cmp === null || cmp >= 0;
757
816
  }
758
817
 
759
- // The installer copies every live file to .prev before it renames new ones
760
- // in, so .prev is a complete, consistent copy of the PREVIOUS install. It is a
761
- // safe place to resume only when that copy is the version this connection
762
- // last ran — the version the host's tool list still describes. Any other
763
- // version would be a swap no gate has seen; crash recovery used to boot
764
- // whatever .prev held.
818
+ // A .prev whose manifest verifies (prevSnapshotAt) is a complete, consistent
819
+ // copy of ONE previous install. It is a safe place to resume only when that
820
+ // copy is the version this connection last ran — the version the host's tool
821
+ // list still describes. Any other version would be a swap no gate has seen;
822
+ // crash recovery used to boot whatever .prev held.
765
823
  previousBaselineTarget(anchor) {
766
824
  if (!anchor?.path || anchor.source === 'rollback') return null;
767
825
  const previous = prevSnapshotTarget(anchor.path);
@@ -770,6 +828,16 @@ class Supervisor {
770
828
  return previous;
771
829
  }
772
830
 
831
+ // A pair that already runs from .prev resumes that same snapshot — while it
832
+ // still verifies and still holds this connection's version. An install
833
+ // rewrites .prev, and a pre-fix installer could rewrite it with a mixed set.
834
+ resumableRollback(anchor) {
835
+ const snapshot = anchor?.path ? prevSnapshotAt(path.dirname(anchor.path)) : null;
836
+ const baseVersion = this.baselineVersion();
837
+ if (!snapshot || !baseVersion || snapshot.version !== String(baseVersion)) return null;
838
+ return path.resolve(snapshot.path) === path.resolve(anchor.path) ? snapshot : null;
839
+ }
840
+
773
841
  // The package's own worker as it is on disk NOW. fallbackTarget carries the
774
842
  // version this supervisor started with, but an install (flat bundle) or an
775
843
  // npx cache refresh (direct-package launch) can replace the file since, and a
@@ -888,14 +956,10 @@ class Supervisor {
888
956
 
889
957
  // A full read of the manifest, repeated while it fails integrity (or cannot be
890
958
  // read) for up to WAKE_INTEGRITY_WAIT_MS: an install renames its files one at
891
- // a time and commits the manifest last. Wakes (B3) and boots (K1) wait alike.
959
+ // a time and commits the manifest last. Wakes (B3) and boots (K1) wait alike;
960
+ // settleRuntime re-hashes only when the manifest moves.
892
961
  async settledRuntime(runtime = this.runtimeWatch.read({ force: true })) {
893
- const deadline = Date.now() + WAKE_INTEGRITY_WAIT_MS;
894
- while (!runtime.ok && !runtime.absent && !this.closed && Date.now() < deadline) {
895
- await sleep(WAKE_INTEGRITY_POLL_MS);
896
- runtime = this.runtimeWatch.read({ force: true });
897
- }
898
- return runtime;
962
+ return settleRuntime(this.runtimeWatch, { initial: runtime, stopped: () => this.closed });
899
963
  }
900
964
 
901
965
  // B3 (2026-10-03): the wake re-reads the manifest instead of trusting the
@@ -951,9 +1015,9 @@ class Supervisor {
951
1015
  // sleeping target's path inside the managed directory — resume .prev when it
952
1016
  // holds this connection's version...
953
1017
  // A pair that already resumed from .prev resumes that same copy — while it
954
- // still holds this connection's version (a new install overwrites .prev).
1018
+ // still verifies and holds this connection's version (resumableRollback).
955
1019
  const previous = anchor?.source === 'rollback'
956
- ? (fs.existsSync(anchor.path) && readBakedVersion(anchor.path) === String(this.baselineVersion()) ? anchor : null)
1020
+ ? this.resumableRollback(anchor)
957
1021
  : this.previousBaselineTarget(anchor);
958
1022
  if (previous) {
959
1023
  log(`runtime still fails integrity after ${WAKE_INTEGRITY_WAIT_MS} ms (${runtime.error}) — waking v${previous.version} from .prev`);
@@ -1043,9 +1107,10 @@ class Supervisor {
1043
1107
  // is <brainDir>/klypix-mcp-worker.mjs — inside the directory being renamed —
1044
1108
  // and booting it at once could load a mixed module graph (a new worker beside
1045
1109
  // an old engine, or the reverse). So the boot waits for the install to settle,
1046
- // as a wake does (B3), and then tries the previous worker snapshot. Without
1047
- // one it refuses the connection instead of loading the known-unverified live
1048
- // files. A direct-package launch
1110
+ // as a wake does (B3), and then boots .prev's complete pre-install copy — one
1111
+ // whose own manifest verifies (prevSnapshotAt). Without one it refuses the
1112
+ // connection instead of loading the known-unverified live files; the
1113
+ // integrity error is recorded either way. A direct-package launch
1049
1114
  // keeps its worker outside the managed directory, which no install touches: it
1050
1115
  // boots at once. The host's first requests queue meanwhile (run()).
1051
1116
  async selectInitialTarget() {
@@ -1064,7 +1129,7 @@ class Supervisor {
1064
1129
  log(`runtime still fails integrity after ${WAKE_INTEGRITY_WAIT_MS} ms (${runtime.error}) — starting v${previous.version} from .prev, the complete copy of the previous install`);
1065
1130
  return previous;
1066
1131
  }
1067
- throw new Error(`KLYPIX core files do not verify (${runtime.error}) and no previous worker is available — no worker was started; retry after the install finishes, then /mcp reconnect`);
1132
+ throw new Error(`KLYPIX core files do not verify (${runtime.error}) and no previous worker whose own manifest verifies is available — no worker was started; retry after the install finishes, then /mcp reconnect`);
1068
1133
  }
1069
1134
  }
1070
1135
  if (!runtime.ok) return this.fallbackTarget;
@@ -1532,6 +1597,37 @@ class Supervisor {
1532
1597
  }
1533
1598
  }
1534
1599
 
1600
+ // A hot-swap that failed while the old worker still serves. A deterministic
1601
+ // rejection (new major, breaking tools) stays rejected until a new install or a
1602
+ // reconnect. A TRANSIENT one — an initialize timeout under load, a spawn error,
1603
+ // an exit before activation — used to pin the connection to its old version
1604
+ // just the same (field, 2026-10-03: one 15 s initialize timeout during a
1605
+ // test-heavy hour left a Codex pair on v1.88.0 beside a v1.89.0 install until it
1606
+ // reconnected). It now retries the same target on the swap backoff while the
1607
+ // old worker keeps serving; the signature stays rejected meanwhile so the 1 s
1608
+ // poller cannot restart it early, and the timer lifts it.
1609
+ scheduleSwapRetry(candidate, reason) {
1610
+ const signature = candidate.target.signature;
1611
+ if (this.swapRetrySignature !== signature) {
1612
+ this.swapRetrySignature = signature;
1613
+ this.swapRetryAttempts = 0;
1614
+ }
1615
+ if (this.swapRetryAttempts >= SWAP_RETRY_FACTORS.length) return false;
1616
+ const delay = this.swapRetryBaseMs * SWAP_RETRY_FACTORS[this.swapRetryAttempts++];
1617
+ const rejectedVersion = candidate.version || candidate.target.version;
1618
+ this.rejectedSignature = signature;
1619
+ this.status = 'ready';
1620
+ if (this.swapRetryTimer) clearTimeout(this.swapRetryTimer);
1621
+ this.swapRetryTimer = setTimeout(() => {
1622
+ this.swapRetryTimer = null;
1623
+ if (!this.closed && this.rejectedSignature === signature) this.rejectedSignature = null;
1624
+ }, delay);
1625
+ this.swapRetryTimer.unref?.();
1626
+ this.writeState({ rejectedVersion, swapRetry: { attempt: this.swapRetryAttempts, of: SWAP_RETRY_FACTORS.length, inMs: delay } });
1627
+ log(`kept v${this.active?.version || 'none'}; v${rejectedVersion} failed transiently (${reason}) — retrying in ${Math.round(delay / 1000)} s (${this.swapRetryAttempts}/${SWAP_RETRY_FACTORS.length})`);
1628
+ return true;
1629
+ }
1630
+
1535
1631
  rejectCandidate(reason, terminate = true, { deterministic = false } = {}) {
1536
1632
  const candidate = this.candidate;
1537
1633
  if (!candidate) return;
@@ -1542,6 +1638,7 @@ class Supervisor {
1542
1638
  this.rejectWhileIdle(candidate, reason);
1543
1639
  return;
1544
1640
  }
1641
+ if (this.active && !deterministic && this.scheduleSwapRetry(candidate, reason)) return;
1545
1642
  this.status = this.active ? 'restart-required' : 'recovery-failed';
1546
1643
  if (!this.active) {
1547
1644
  // RECOVERY rejection: transient spawn failures (0xC0000142-class) must
@@ -1646,6 +1743,9 @@ class Supervisor {
1646
1743
  this.rejectedSignature = rejection ? rejection.signature : null;
1647
1744
  this.recoveryAttempts = 0; // a committed worker resets the retry budget
1648
1745
  if (this.recoveryTimer) { clearTimeout(this.recoveryTimer); this.recoveryTimer = null; }
1746
+ this.swapRetryAttempts = 0; // and the hot-swap retry budget
1747
+ this.swapRetrySignature = null;
1748
+ if (this.swapRetryTimer) { clearTimeout(this.swapRetryTimer); this.swapRetryTimer = null; }
1649
1749
  this.status = rejection ? 'restart-required' : 'ready';
1650
1750
  this.lastError = rejection ? rejection.reason : null;
1651
1751
  this.runtimeError = null;
@@ -1941,6 +2041,7 @@ class Supervisor {
1941
2041
  clearTimeout(this.autoUpdateStarter);
1942
2042
  clearInterval(this.autoUpdatePoller);
1943
2043
  if (this.recoveryTimer) { clearTimeout(this.recoveryTimer); this.recoveryTimer = null; }
2044
+ if (this.swapRetryTimer) { clearTimeout(this.swapRetryTimer); this.swapRetryTimer = null; }
1944
2045
  // Real shutdown grace: stdin EOF lets the worker run its own presence
1945
2046
  // cleanup (stopRuntimePresence/removeSession). An instant SIGTERM is
1946
2047
  // TerminateProcess on Windows — the cleanup never runs and every normally
@@ -1967,6 +2068,8 @@ export const __test = {
1967
2068
  compareSemver,
1968
2069
  atomicJson,
1969
2070
  createRuntimeWatch,
2071
+ settleRuntime,
2072
+ prevSnapshotTarget,
1970
2073
  cleanSupervisorStateDir,
1971
2074
  deadSupervisorReceipt,
1972
2075
  pidState,