klypix-mcp 1.56.0 → 1.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.56.0",
3
+ "version": "1.57.0",
4
4
  "description": "Shared project brain and MCP coordination server for multi-agent coding.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -26,6 +26,7 @@ import {
26
26
  inspectAutoUpdate,
27
27
  spawnAutoUpdateHelper,
28
28
  } from './mcp-auto-update.mjs';
29
+ import { removeSession, upsertSession } from './agent-presence.mjs';
29
30
 
30
31
  const INTERNAL_PREFIX = '__klypix_supervisor__';
31
32
  const DEFAULT_POLL_MS = 1000;
@@ -286,6 +287,25 @@ class Supervisor {
286
287
  this.recoveryAttempts = 0;
287
288
  this.recoveryTimer = null;
288
289
  this.lastFailedSignature = null;
290
+ // RAM Phase 2 — idle worker hibernation. An idle connection pays for a
291
+ // whole worker process it is not using (measured: 11 idle pairs = 1,445 MB
292
+ // with ZERO models resident, so this is process baseline, not semantics).
293
+ // After this much host silence the worker half is retired; the next host
294
+ // message wakes it through the SAME queue → candidate → commit → flush path
295
+ // recovery already uses, with the task scope replayed. Set 0 to disable
296
+ // (instant rollback to today's behavior; no data/format/protocol change).
297
+ const hibernateEnv = Number(process.env.KLYPIX_WORKER_HIBERNATE_MS);
298
+ this.hibernateIdleMs = Number.isFinite(hibernateEnv) ? Math.max(0, hibernateEnv) : 600_000;
299
+ this.hibernatedTarget = null;
300
+ this.hibernatedAt = null;
301
+ this.hibernations = 0;
302
+ this.hibernateProbeInFlight = false;
303
+ this.hibernateSkipReason = null;
304
+ // Presence identity of the hibernated connection. While the worker is gone
305
+ // the SUPERVISOR keeps its lane row fresh, so peers see exactly what they
306
+ // saw before — hibernation buys RAM without spending coordination.
307
+ this.presenceIdentity = null;
308
+ this.presenceHeartbeat = null;
289
309
  }
290
310
 
291
311
  writeState(extra = {}) {
@@ -297,6 +317,13 @@ class Supervisor {
297
317
  parentPid: this.parentPid,
298
318
  vault: this.vaultArg ? this.vaultArg.replace(/\\/g, '/') : null,
299
319
  defaultRoot: this.defaultRoot,
320
+ hibernation: {
321
+ idleMs: this.hibernateIdleMs,
322
+ hibernated: this.status === 'hibernated',
323
+ since: this.status === 'hibernated' ? this.hibernatedAt : null,
324
+ count: this.hibernations,
325
+ skipReason: this.hibernateSkipReason || null,
326
+ },
300
327
  cwd: process.cwd().replace(/\\/g, '/'),
301
328
  bootedAt: this.bootedAt,
302
329
  updatedAt: new Date().toISOString(),
@@ -327,6 +354,124 @@ class Supervisor {
327
354
  } catch { /* diagnostics must never break the transport */ }
328
355
  }
329
356
 
357
+ // Retire the worker half of an idle pair. Deliberately conservative: only a
358
+ // settled, fully-handshaked, request-free connection hibernates, and only
359
+ // when we can prove we are able to wake it (the host's initialize is what a
360
+ // respawned worker replays).
361
+ async maybeHibernate() {
362
+ if (this.closed || !this.hibernateIdleMs || this.hibernateProbeInFlight) return;
363
+ if (!this.active || this.candidate || this.standby) return;
364
+ if (this.status !== 'ready') return;
365
+ if (this.hostRequests.size || this.workerRequests.size || this.hostQueue.length) return;
366
+ if (!this.initializeRequest || !this.hostInitialized) return;
367
+ const last = Date.parse(this.lastHostMessageAt || this.bootedAt);
368
+ if (!Number.isFinite(last) || Date.now() - last < this.hibernateIdleMs) return;
369
+
370
+ // PRESENCE IS NON-NEGOTIABLE. A worker's graceful stop calls removeSession,
371
+ // so hibernating would delete a LIVE session from every peer's view unless
372
+ // something keeps its lane row fresh. Probe the worker for its presence
373
+ // identity; the supervisor then heartbeats that row itself while the worker
374
+ // sleeps, and pins the SAME session id into the respawned worker's env so
375
+ // the wake never mints a second row. Identity unavailable → never hibernate.
376
+ this.hibernateProbeInFlight = true;
377
+ let identity = null;
378
+ let probeFailed = false;
379
+ try {
380
+ const probe = await this.sendInternal(this.active, 'tools/call', {
381
+ name: 'brain_sync',
382
+ arguments: { phase: 'checkpoint', include_context: false },
383
+ }, 4000);
384
+ const structured = probe?.structuredContent || null;
385
+ if (!structured || structured.reason === 'no-project-brain') {
386
+ identity = null; // no lane row exists → nothing to keep alive
387
+ } else if (structured.brain && structured.self?.id) {
388
+ identity = {
389
+ brainPath: String(structured.brain),
390
+ id: String(structured.self.id),
391
+ client: structured.self.client || 'unknown',
392
+ surface: structured.self.surface || null,
393
+ branch: structured.self.branch || null,
394
+ };
395
+ } else {
396
+ probeFailed = true; // owns presence but unidentifiable → refuse
397
+ }
398
+ } catch {
399
+ probeFailed = true;
400
+ } finally {
401
+ this.hibernateProbeInFlight = false;
402
+ }
403
+ // Conditions can change across the await — re-verify before retiring.
404
+ if (this.closed || !this.active || this.candidate || this.standby) return;
405
+ if (this.hostRequests.size || this.workerRequests.size || this.hostQueue.length) return;
406
+ if (probeFailed) {
407
+ this.hibernateSkipReason = 'presence-identity-unavailable';
408
+ return;
409
+ }
410
+ this.hibernateSkipReason = null;
411
+ this.presenceIdentity = identity;
412
+ const worker = this.active;
413
+ this.hibernatedTarget = worker.target;
414
+ this.hibernatedAt = new Date().toISOString();
415
+ this.hibernations++;
416
+ this.active = null;
417
+ this.status = 'hibernated';
418
+ // A connection that owns a row must NOT let the worker remove it on the way
419
+ // out; one without a row retires gracefully as usual.
420
+ this.retireWorker(worker, 350, { preservePresence: Boolean(this.presenceIdentity) });
421
+ this.startPresenceHeartbeat();
422
+ this.writeState();
423
+ log(`worker hibernated after ${Math.round((Date.now() - last) / 1000)}s idle — wakes on the next request${this.presenceIdentity ? ' (presence held by the supervisor)' : ''}`);
424
+ }
425
+
426
+ // Re-register the sleeping connection's lane row on the SAME cadence the
427
+ // worker used, through the SAME shared upsertSession (one implementation,
428
+ // one lock). Fields not supplied are preserved by the merge, so a declared
429
+ // intent/file scope survives hibernation untouched.
430
+ startPresenceHeartbeat() {
431
+ this.stopPresenceHeartbeat();
432
+ const who = this.presenceIdentity;
433
+ if (!who) return;
434
+ const beat = () => {
435
+ try {
436
+ upsertSession({
437
+ brainPath: who.brainPath,
438
+ id: who.id,
439
+ client: who.client,
440
+ surface: who.surface,
441
+ branch: who.branch,
442
+ channel: 'mcp',
443
+ event: 'McpHibernated',
444
+ hostPid: this.parentPid,
445
+ });
446
+ } catch { /* presence upkeep is best-effort; TTL is the backstop */ }
447
+ };
448
+ // ORDER MATTERS (caught by real-worker measurement, not by the fixture):
449
+ // the retiring worker calls removeSession during its shutdown grace, so a
450
+ // single beat fired now is immediately UNDONE and the row would stay gone
451
+ // until the 60s tick — i.e. the session disappears from every peer for a
452
+ // minute. Re-assert across the whole grace window, then settle into the
453
+ // normal cadence.
454
+ beat();
455
+ for (const delay of [500, 1_200, 2_500, 5_000]) {
456
+ const t = setTimeout(() => { if (this.presenceHeartbeat) beat(); }, delay);
457
+ t.unref?.();
458
+ }
459
+ this.presenceHeartbeat = setInterval(beat, 60_000);
460
+ this.presenceHeartbeat.unref?.();
461
+ }
462
+
463
+ stopPresenceHeartbeat() {
464
+ if (this.presenceHeartbeat) clearInterval(this.presenceHeartbeat);
465
+ this.presenceHeartbeat = null;
466
+ }
467
+
468
+ wake() {
469
+ if (this.closed || this.active || this.candidate) return;
470
+ const target = this.hibernatedTarget || this.selectInitialTarget();
471
+ log('waking hibernated worker');
472
+ this.startCandidate(target, { recovery: true });
473
+ }
474
+
330
475
  selectInitialTarget() {
331
476
  const runtime = readRuntimeTarget(this.runtimeManifest, { allowExternal: this.allowExternal });
332
477
  if (!runtime.ok) return this.fallbackTarget;
@@ -342,6 +487,11 @@ class Supervisor {
342
487
  KLYPIX_MCP_SUPERVISED: '1',
343
488
  KLYPIX_MCP_SUPERVISOR_PID: String(process.pid),
344
489
  KLYPIX_MCP_CONNECTION_ID: this.connectionId,
490
+ // Pin the session id across a hibernation wake (KLYPIX_SESSION_ID wins
491
+ // resolveMcpSessionId's precedence chain) so the woken worker adopts the
492
+ // row the supervisor kept alive instead of minting a second one. Hosts
493
+ // that export their own id already resolve to the same value.
494
+ ...(this.presenceIdentity?.id ? { KLYPIX_SESSION_ID: this.presenceIdentity.id } : {}),
345
495
  },
346
496
  stdio: ['pipe', 'pipe', 'pipe'],
347
497
  windowsHide: true,
@@ -591,6 +741,10 @@ class Supervisor {
591
741
  return;
592
742
  }
593
743
  this.hostQueue.push(message);
744
+ // A hibernated pair wakes on demand: the queued message flushes to the
745
+ // new worker the moment the candidate commits, so the host sees latency,
746
+ // never an error, and never a reconnect.
747
+ if (this.status === 'hibernated') this.wake();
594
748
  return;
595
749
  }
596
750
  if (message?.method === 'initialize' && Object.prototype.hasOwnProperty.call(message, 'id')) {
@@ -753,6 +907,9 @@ class Supervisor {
753
907
 
754
908
  maybeCommitCandidate() {
755
909
  if (!this.candidate?.ready) return;
910
+ // A woken worker owns its lane row again — hand presence back before it
911
+ // becomes active so exactly one writer heartbeats at any moment.
912
+ this.stopPresenceHeartbeat();
756
913
  this.expireAbandonedRequests();
757
914
  if (this.hostRequests.size || this.workerRequests.size) return;
758
915
  const next = this.candidate;
@@ -798,9 +955,18 @@ class Supervisor {
798
955
  }
799
956
  }
800
957
 
801
- retireWorker(worker, graceMs = 250) {
958
+ retireWorker(worker, graceMs = 250, { preservePresence = false } = {}) {
802
959
  if (!worker || worker.exited) return;
803
960
  worker.retiring = true;
961
+ if (preservePresence) {
962
+ // HIBERNATION ONLY. stdin EOF triggers the worker's graceful stop, which
963
+ // REMOVES its presence row — correct when the connection is ending, wrong
964
+ // when it is merely sleeping (the supervisor is about to hold that row).
965
+ // Signal-terminate instead so the row is never removed and peers observe
966
+ // no gap at all, not even a sub-second one.
967
+ try { worker.child.kill('SIGTERM'); } catch { /* */ }
968
+ return;
969
+ }
804
970
  try { worker.child.stdin.end(); } catch { /* */ }
805
971
  if (graceMs <= 0) {
806
972
  try { worker.child.kill('SIGTERM'); } catch { /* */ }
@@ -908,6 +1074,11 @@ class Supervisor {
908
1074
  );
909
1075
  this.autoUpdatePoller.unref?.();
910
1076
 
1077
+ if (this.hibernateIdleMs) {
1078
+ this.hibernationTimer = setInterval(() => { this.maybeHibernate().catch(() => {}); }, Math.max(1_000, Math.min(60_000, this.hibernateIdleMs)));
1079
+ this.hibernationTimer.unref?.();
1080
+ }
1081
+
911
1082
  // Host watchdog: shutdown is otherwise 100% stdin-EOF-dependent, and a
912
1083
  // host that dies holding pipes open (or a wedged IDE) pinned this pair —
913
1084
  // supervisor AND worker — indefinitely. The parent pid is a cheap,
@@ -936,6 +1107,17 @@ class Supervisor {
936
1107
  this.closed = true;
937
1108
  clearInterval(this.poller);
938
1109
  clearInterval(this.parentWatchdog);
1110
+ clearInterval(this.hibernationTimer);
1111
+ // The connection is ending: stop holding its row and remove it, so a
1112
+ // hibernated-then-closed session never lingers as a ghost peer.
1113
+ this.stopPresenceHeartbeat();
1114
+ if (this.status === 'hibernated' && this.presenceIdentity) {
1115
+ const who = this.presenceIdentity;
1116
+ this.presenceIdentity = null;
1117
+ // Same removal the worker performs on its own graceful stop.
1118
+ try { removeSession({ brainPath: who.brainPath, id: who.id, channel: 'mcp' }); }
1119
+ catch { /* TTL prunes it either way */ }
1120
+ }
939
1121
  clearTimeout(this.autoUpdateStarter);
940
1122
  clearInterval(this.autoUpdatePoller);
941
1123
  if (this.recoveryTimer) { clearTimeout(this.recoveryTimer); this.recoveryTimer = null; }
@@ -181,6 +181,10 @@ export function buildRuntimeReport({
181
181
  if (launchers.length) flags.push('npx-launcher-chain');
182
182
  if (!requestedVault || requestedVault === '.') flags.push('default-root');
183
183
  if (!host) flags.push('host-unattributed');
184
+ // Phase 2 visibility: a hibernated pair is a supervisor with NO worker by
185
+ // design — without this flag it reads like a missing/crashed worker.
186
+ const hibernating = state?.status === 'hibernated';
187
+ if (hibernating) flags.push('worker-hibernated');
184
188
  connections.push({
185
189
  id: state?.connectionId || `pid-${supervisor?.pid || worker?.pid}`,
186
190
  client: state?.clientInfo?.name || (host ? classifyHostProcess(host) : 'unknown'),
@@ -206,6 +210,9 @@ export function buildRuntimeReport({
206
210
  vault,
207
211
  requestedVault,
208
212
  rssMb: roundMb(rssBytes),
213
+ hibernation: state?.hibernation
214
+ ? { hibernated: hibernating, idleMs: state.hibernation.idleMs ?? null, since: state.hibernation.since || null, count: state.hibernation.count || 0 }
215
+ : null,
209
216
  flags,
210
217
  processIds,
211
218
  });
@@ -242,13 +249,30 @@ export function buildRuntimeReport({
242
249
  .map(([key, connectionIds]) => ({ key, connectionIds, verdict: 'parallel-not-proven-duplicate' }));
243
250
 
244
251
  const totalMb = Math.round((roleTotals.workersMb + roleTotals.supervisorsMb + roleTotals.launchersMb) * 10) / 10;
252
+ // Measured, not modelled: the mean resident worker is what a hibernated pair
253
+ // is NOT paying. Reported only when at least one worker is resident, so the
254
+ // number is always derived from this machine rather than a guess.
255
+ const residentWorkers = connections.filter((item) => item.worker);
256
+ const hibernated = connections.filter((item) => item.hibernation?.hibernated);
257
+ const avgWorkerMb = residentWorkers.length
258
+ ? Math.round((residentWorkers.reduce((sum, item) => sum + number(item.worker.rssMb), 0) / residentWorkers.length) * 10) / 10
259
+ : null;
245
260
  return {
246
261
  schemaVersion: 1,
247
262
  sampledAt: new Date(sampledAt).toISOString(),
248
263
  platform,
249
264
  passive: true,
250
265
  mutated: false,
251
- totals: { connections: connections.length, ...roleTotals, totalMb },
266
+ totals: {
267
+ connections: connections.length,
268
+ ...roleTotals,
269
+ totalMb,
270
+ hibernatedConnections: hibernated.length,
271
+ avgResidentWorkerMb: avgWorkerMb,
272
+ estimatedHibernationSavingsMb: avgWorkerMb !== null && hibernated.length
273
+ ? Math.round(avgWorkerMb * hibernated.length * 10) / 10
274
+ : 0,
275
+ },
252
276
  connections: connections.sort((a, b) => b.rssMb - a.rssMb),
253
277
  parallelConnectionGroups,
254
278
  safety: {
@@ -276,8 +300,11 @@ export function formatRuntimeReport(report) {
276
300
  const lines = [
277
301
  `KLYPIX RUNTIME V2 — PASSIVE — ${report?.sampledAt || ''}`,
278
302
  `Connections ${t.connections || 0} · workers ${t.workersMb || 0} MB · supervisors ${t.supervisorsMb || 0} MB · launchers ${t.launchersMb || 0} MB · total ${t.totalMb || 0} MB`,
303
+ t.hibernatedConnections
304
+ ? `Hibernated ${t.hibernatedConnections} connection(s) — about ${t.estimatedHibernationSavingsMb} MB not resident (mean resident worker ${t.avgResidentWorkerMb} MB); each wakes on its next request.`
305
+ : '',
279
306
  '',
280
- ];
307
+ ].filter((line, index) => line !== '' || index > 1);
281
308
  for (const item of report?.connections || []) {
282
309
  const host = item.host ? `${item.host.kind}:${item.host.pid}` : 'host:unknown';
283
310
  const processBits = [