klypix-mcp 1.72.0 → 1.73.2

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
@@ -79,7 +79,7 @@ npx klypix-mcp conformance
79
79
 
80
80
  It runs in a temporary fixture and touches nothing else. It checks tool discovery, task memory,
81
81
  truthful peer reporting, overlap surfacing, proactive logging, and in-band delivery of a peer note.
82
- It verifies 15 required coordination behaviours — not the 26 tools, and not the retrieval engine.
82
+ It verifies 15 required coordination behaviours — not the 22 tools, and not the retrieval engine.
83
83
 
84
84
  ---
85
85
 
@@ -102,7 +102,7 @@ result** before it exits:
102
102
  editors Claude Code · Cursor · Codex · Gemini CLI · Antigravity · VS Code
103
103
  wired 9 file(s) · 9 updated (skipped 5 for tools you don't have)
104
104
  git lossless .klypix merge driver registered
105
- verified ✓ 26 tools reachable via .mcp.json (892ms)
105
+ verified ✓ 22 tools reachable via .mcp.json (892ms)
106
106
  ```
107
107
 
108
108
  That last line is the point. MCP config fails **silently** — a wrong entry means the server never
@@ -542,10 +542,6 @@ The MCP verbs below are what agents call. These are what **you** call:
542
542
  | `project_map_context` | Read-only, bounded code-graph evidence beside correction-aware brain context, with exact-path review proposals; external artifacts (e.g. Graphify) are supported but never installed or run locally |
543
543
  | `project_map_scan` | KLYPIX's own zero-install scanner: gitignore-aware file inventory + file-level import edges (relative, tsconfig-alias, and monorepo-workspace imports resolved) written to `klypix-map/graph.json` — which then serves `project_map_context` automatically |
544
544
  | `project_map_drift` | Read-only drift report: brain cards whose referenced files are gone or moved (with rename candidates), plus a headline when the checkout itself is behind its origin default branch |
545
- | `remote_status` | Inspect the local KLYPIX tray relay and its verified Remote capabilities |
546
- | `remote_sessions` | List coding-agent sessions with exact provider, host-binding and capability receipts |
547
- | `remote_actions` | List pending questions, approvals, failures, conflicts and reviews reported by supported providers |
548
- | `remote_command` | Control one exact verified coding-agent session using a fresh capability receipt; unsupported operations fail closed |
549
545
  | `canvas_view` | Returns the board as a structured render spec plus a text summary, and declares an MCP Apps (SEP-1865) UI resource |
550
546
  | `read_canvas` | A canvas as markdown (cards, connection graph, `[[links]]`, `#tags`) |
551
547
  | `search_canvases` | Search across canvases by name and content |
@@ -246,7 +246,7 @@ const flatten = (code) => code
246
246
  .replace(/\.\.\/src\/klypix-(core|format)\.mjs/g, './klypix-$1.mjs')
247
247
  // brain-doctor + agent-rules (the server's lazy `import('../src/brain-doctor.mjs')`
248
248
  // for the brain_doctor tool) → flat sibling refs in the runtime layout.
249
- .replace(/\.\.\/src\/(bench|brain-doctor|agent-presence|agent-rules|finding-routing|mcp-presence|mcp-supervisor|mcp-auto-update|presence-relay|semantic-memory|runtime-inspector|project-graph|git-capture-install|remote-client)\.mjs/g, './$1.mjs')
249
+ .replace(/\.\.\/src\/(bench|brain-doctor|agent-presence|agent-rules|finding-routing|mcp-presence|mcp-supervisor|mcp-auto-update|presence-relay|semantic-memory|runtime-inspector|project-graph|git-capture-install)\.mjs/g, './$1.mjs')
250
250
  .replace(/klypix-worker\.mjs/g, 'klypix-mcp-worker.mjs')
251
251
  .replace(/const PKG_VERSION = \(\(\) => \{[\s\S]*?\}\)\(\);/, `const PKG_VERSION = '${VERSION}'; // baked at install (flat layout has no package.json)`);
252
252
 
@@ -380,7 +380,7 @@ try {
380
380
  // canvas-view-app.html is the canvas_view MCP App UI — staged raw (an HTML
381
381
  // file must never get a JS-comment banner) beside the flat server, which
382
382
  // resolves it via its ./canvas-view-app.html candidate path.
383
- for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'brain-graveyard.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'repo-state.mjs', 'result-reconcile.mjs', 'finding-routing.mjs', 'presence-relay.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'remote-client.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
383
+ for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'brain-graveyard.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'repo-state.mjs', 'result-reconcile.mjs', 'finding-routing.mjs', 'presence-relay.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
384
384
  const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
385
385
  }
386
386
  for (const [src, dst] of [
@@ -39,7 +39,6 @@ import {
39
39
  registerProjectBrain,
40
40
  spawnAutoUpdateHelper,
41
41
  } from '../src/mcp-auto-update.mjs';
42
- import { remoteActions, remoteCommand, remoteSessions, remoteStatus } from '../src/remote-client.mjs';
43
42
  // Namespace import (already in-process via the klypix-core chain, so zero added
44
43
  // load cost) so a bundle whose klypix-format predates classifyDecay degrades
45
44
  // gracefully — a named import of a missing export would kill the whole server.
@@ -408,91 +407,6 @@ const toContent = (r) => {
408
407
  return result;
409
408
  };
410
409
 
411
- // Return raw MCP results here. The universal registerTool wrapper above owns
412
- // the single presence/message transition for every non-brain_sync call. A
413
- // helper-level decoration would offer and then acknowledge one note inside the
414
- // same Remote call, violating delivery-v3's later-action invariant.
415
- const remoteToolResult = (label, value) => ({
416
- content: [{ type: 'text', text: `${label}\n\n${JSON.stringify(value, null, 2)}` }],
417
- structuredContent: { result: value },
418
- });
419
- const remoteToolError = (error) => {
420
- const message = typeof error?.message === 'string' && /^KLYPIX_[A-Z0-9_]+$/.test(error.message)
421
- ? error.message : 'KLYPIX_REMOTE_REQUEST_REJECTED';
422
- return {
423
- content: [{ type: 'text', text: `${message}. The KLYPIX desktop window may be closed, but its tray relay must be running and Remote control must be enabled.` }],
424
- isError: true,
425
- };
426
- };
427
- const remoteProjectRoot = () => (mcpPresence.brainPath ? path.dirname(mcpPresence.brainPath) : null);
428
-
429
- server.registerTool('remote_status', {
430
- title: 'Inspect the local KLYPIX Remote relay',
431
- description: 'Reports whether the KLYPIX tray relay, discovery, provider hooks, verified controls, phone pairing, and encrypted network relay are active. The desktop WINDOW may be closed; KLYPIX must still be running in the tray and the PC must be awake.',
432
- annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false },
433
- inputSchema: {},
434
- }, async () => {
435
- try { return remoteToolResult('KLYPIX Remote status', await remoteStatus({ projectRoot: remoteProjectRoot() })); }
436
- catch (error) { return remoteToolError(error); }
437
- });
438
-
439
- server.registerTool('remote_sessions', {
440
- title: 'List coding-agent sessions by KLYPIX project',
441
- description: 'Lists local coding-agent sessions with their project/path binding, current state, active host binding, and exact capability receipts. Treat each receipt as the truth for that specific provider + version + live session; never infer a missing action. The KLYPIX tray relay must be running.',
442
- annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false },
443
- inputSchema: {},
444
- }, async () => {
445
- try { return remoteToolResult('KLYPIX Remote sessions', await remoteSessions({ projectRoot: remoteProjectRoot() })); }
446
- catch (error) { return remoteToolError(error); }
447
- });
448
-
449
- server.registerTool('remote_actions', {
450
- title: 'List pending coding-agent decisions',
451
- description: 'Lists pending questions, permission requests, confirmations, failures, conflicts, and reviews reported by supported provider hooks. Approval/rejection still requires the exact session capability receipt and request digest. The KLYPIX tray relay must be running.',
452
- annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false },
453
- inputSchema: {},
454
- }, async () => {
455
- try { return remoteToolResult('KLYPIX Remote actions', await remoteActions({ projectRoot: remoteProjectRoot() })); }
456
- catch (error) { return remoteToolError(error); }
457
- });
458
-
459
- server.registerTool('remote_command', {
460
- title: 'Control one verified coding-agent session',
461
- description: 'Sends one command to the exact machine/provider/session and host binding named by a fresh remote_sessions capability receipt. Supports opening the exact provider session, messages, images/files, interrupt, approve/reject, close/archive/resume only when that exact receipt says the operation is available. File paths are local to this PC. The KLYPIX tray relay must be running and verified controls enabled.',
462
- annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
463
- inputSchema: {
464
- machine_id: z.string().min(1).max(256),
465
- provider: z.enum(['antigravity-agent', 'claude-code', 'codex', 'cursor-agent', 'gemini-cli', 'grok-build', 'kimi-code', 'opencode', 'pi', 'qoder', 'mistral-vibe', 'unknown']),
466
- external_session_id: z.string().min(1).max(512),
467
- operation: z.enum(['send-text', 'send-message', 'attach-image', 'attach-file', 'interrupt', 'approve', 'reject', 'close', 'archive', 'resume', 'open-provider']),
468
- capability_receipt_id: z.string().min(1).max(256).describe('Fresh receipt id for this exact operation from remote_sessions.'),
469
- binding_id: z.string().min(1).max(256).describe('Fresh host binding id named by the receipt.'),
470
- request_digest: z.string().regex(/^[0-9a-f]{64}$/).optional().describe('Required for approve/reject; copy it from the pending remote action.'),
471
- text: z.string().min(1).max(20000).optional(),
472
- replace_draft: z.boolean().optional(),
473
- attachments: z.array(z.object({
474
- path: z.string().min(1).max(2048).describe('Absolute local file path on this PC.'),
475
- kind: z.enum(['image', 'video', 'audio', 'document', 'file']),
476
- mime_type: z.string().min(1).max(128).optional(),
477
- })).max(25).optional(),
478
- },
479
- }, async ({ machine_id, provider, external_session_id, operation, capability_receipt_id, binding_id, request_digest, text, replace_draft, attachments }, extra) => {
480
- try {
481
- const receipt = await remoteCommand({
482
- machineId: machine_id, provider, externalSessionId: external_session_id, operation,
483
- capabilityReceiptId: capability_receipt_id, bindingId: binding_id,
484
- requestDigest: request_digest, text, replaceDraft: replace_draft,
485
- attachments: attachments?.map((attachment) => ({
486
- path: attachment.path, kind: attachment.kind, mimeType: attachment.mime_type,
487
- })),
488
- }, {
489
- projectRoot: remoteProjectRoot(),
490
- actionIdentity: extra.klypixRequestIdentity?.actionId,
491
- });
492
- return remoteToolResult('KLYPIX Remote command receipt', receipt);
493
- } catch (error) { return remoteToolError(error); }
494
- });
495
-
496
410
  server.registerTool('list_canvases', {
497
411
  title: 'List KLYPIX canvases',
498
412
  description: 'List all .klypix / .any canvas files in the vault, with card and connection counts.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.72.0",
3
+ "version": "1.73.2",
4
4
  "description": "Shared project brain and MCP coordination server for multi-agent coding.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -79,12 +79,11 @@
79
79
  "node": ">=18"
80
80
  },
81
81
  "scripts": {
82
- "test:remote": "node test/remote-client.mjs",
83
82
  "test:project-graph": "node test/project-graph.mjs",
84
83
  "bench": "node bin/klypix-mcp.mjs bench",
85
84
  "test:bench": "node test/bench.mjs",
86
85
  "pretest": "node test/publish-workflow.mjs",
87
- "test": "node test/publish-verdict.mjs && node test/remote-client.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/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/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/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/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.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/git-tools.mjs && node test/uninstall.mjs",
86
+ "test": "node test/publish-verdict.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/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/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/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.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/git-tools.mjs && node test/uninstall.mjs",
88
87
  "test:memory": "node test/memory-runtime.mjs",
89
88
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
90
89
  "runtime": "node bin/klypix-runtime.mjs"
@@ -598,6 +598,12 @@ const publicSession = (session, now) => {
598
598
  // Only real prompt/tool work stamps activityAt; heartbeats deliberately
599
599
  // preserve it, so a fresh stamp means work, not merely a live socket.
600
600
  const UNDECLARED_ACTIVE_MS = 10 * 60 * 1000;
601
+
602
+ // How much of the ~2h release lease must remain before the HOLDER is warned.
603
+ // 25 minutes is longer than a full desktop candidate build + verify, so a
604
+ // holder who sees the warning still has time to finish or refresh rather than
605
+ // losing the lease mid-release.
606
+ const RELEASE_LEASE_WARN_MS = 25 * 60 * 1000;
601
607
  const isUndeclaredActive = (session, now) =>
602
608
  !isTaskSession(session)
603
609
  && Number(session?.activityAt || 0) > 0
@@ -1829,6 +1835,11 @@ export function createMcpPresence({
1829
1835
  : undefined;
1830
1836
  if (started && resolved === vault
1831
1837
  && (selectedBrainPath === undefined || selectedBrainPath === brainPath)) {
1838
+ // Same reason as the sync() adoption above: a reconnect to the vault we
1839
+ // are already on must still re-adopt the freshly validated binding, or a
1840
+ // brain rewritten since the last bind leaves this connection guarded by a
1841
+ // dead inode.
1842
+ if (validatedSelection?.projectBinding) currentProjectBinding = validatedSelection.projectBinding;
1832
1843
  return touch({ event: 'McpReconnect', includePresence: true });
1833
1844
  }
1834
1845
  if (started) stop({
@@ -2299,6 +2310,19 @@ export function createMcpPresence({
2299
2310
  clientInfoPrepared: preparedClientInfo,
2300
2311
  branchPrepared: preparedBranch,
2301
2312
  };
2313
+ // Adopt the preflight's binding on BOTH arms, not just the start() arm.
2314
+ // A same-project re-sync takes touch(), which never reassigns — so the
2315
+ // guard kept comparing a binding whose brain inode had already been retired
2316
+ // by an atomic write (every managed brain write is writeFileSync+rename, so
2317
+ // ANY write by anyone, including this session's own Stop hook, mints a new
2318
+ // inode). The result was a permanent deadlock: brain_sync succeeded and
2319
+ // reported the right project while every other verb was rejected, and the
2320
+ // only refresh path was itself gated behind the failing check. This binding
2321
+ // was already validated three times earlier in THIS call (preflightSync →
2322
+ // consumeSyncPreflight → revalidateConsumedSyncPreflight), so adopting it is
2323
+ // strictly more current than retaining the stale one and grants no new
2324
+ // authority — the canonical-root and escape checks already ran on it.
2325
+ if (projectBinding) currentProjectBinding = projectBinding;
2302
2326
  let report = started && requestedVault === vault && resultBrainPath === brainPath
2303
2327
  ? touch(details)
2304
2328
  : start(requestedVault, details, {
@@ -2454,6 +2478,10 @@ export function createMcpPresence({
2454
2478
  {
2455
2479
  const leaseStamp = now();
2456
2480
  let outcome = null;
2481
+ // Set when the holder's own sync arrived with the lease already close to
2482
+ // lapsing; reported after the refresh so the holder learns the habit that
2483
+ // protects them, not merely that nothing broke this time.
2484
+ let nearExpiryBeforeRefreshMs = null;
2457
2485
  if (releaseIntentChecked.provided && completing) {
2458
2486
  // The DECLARATION is ignored on completion, but a REAL completion must
2459
2487
  // still free a lease this session holds — a holder who kept attaching
@@ -2528,6 +2556,17 @@ export function createMcpPresence({
2528
2556
  const freed = freeReleaseLease({ brainPath, sessionId, home, now: leaseStamp });
2529
2557
  if (freed.status === 'released') outcome = freed;
2530
2558
  } else if (!completing) {
2559
+ // Snapshot BEFORE the refresh. A holder's sync refreshes the lease, so
2560
+ // by the time we report, expiry has already moved out and "expiring
2561
+ // soon" is never true — yet the risk was real a millisecond earlier.
2562
+ // The field failure was a holder who ran a 40-minute build with no
2563
+ // sync in it and lost the lease silently; the useful moment to say so
2564
+ // is the sync that arrives while the clock is still low.
2565
+ const preRefresh = readReleaseLease({ brainPath, home, now: leaseStamp });
2566
+ if (preRefresh && preRefresh.holderId === sessionId && preRefresh.expiresAt) {
2567
+ const leftBefore = Math.max(0, preRefresh.expiresAt - leaseStamp);
2568
+ if (leftBefore <= RELEASE_LEASE_WARN_MS) nearExpiryBeforeRefreshMs = leftBefore;
2569
+ }
2531
2570
  const refreshed = refreshReleaseLease({ brainPath, sessionId, home, now: leaseStamp });
2532
2571
  // 'lease-lost' = THIS session held the lease but it lapsed (TTL passed
2533
2572
  // with no checkpoint). Surfacing it beats silent pruning: a holder who
@@ -2626,6 +2665,26 @@ export function createMcpPresence({
2626
2665
  releaseText = `KLYPIX release lease freed: completion released the exclusive release lease (v${outcome.lease?.version} from ${outcome.lease?.ref}).`;
2627
2666
  } else if (active) {
2628
2667
  releaseLease = { status: 'held', holder: holderBlock };
2668
+ // The holder must never learn about expiry by noticing the footer
2669
+ // vanished — which is exactly what happened in the field on
2670
+ // 2026-08-16, mid-release-build. The 'lease-lost' message this design
2671
+ // relies on is UNREACHABLE in any project that has peers: a non-holder's
2672
+ // sync meets the expired lease first, prunes it (status 'stale-pruned',
2673
+ // write:true, lease:null) and is told nothing, so the holder's next sync
2674
+ // takes the no-lease fast path and never reaches the lease-lost branch.
2675
+ // Warning while the lease still EXISTS is therefore the last moment the
2676
+ // holder can still act on it, and it needs no cross-session state.
2677
+ }
2678
+ // Holder-facing expiry telemetry, attached to whichever branch produced
2679
+ // the block. Always reporting expiresInMs is half the fix on its own: the
2680
+ // holder can see the clock instead of inferring it from a footer.
2681
+ if (releaseLease && holderBlock && holderBlock.sessionId === sessionId && holderBlock.expiresAt) {
2682
+ releaseLease.expiresInMs = Math.max(0, holderBlock.expiresAt - leaseStamp);
2683
+ if (nearExpiryBeforeRefreshMs !== null) {
2684
+ releaseLease.expiringSoon = true;
2685
+ releaseLease.nearExpiryBeforeRefreshMs = nearExpiryBeforeRefreshMs;
2686
+ releaseText = `KLYPIX release lease expires in ~${Math.max(1, Math.round(nearExpiryBeforeRefreshMs / 60000))} min — refreshed by this sync. Only a brain_sync from THIS session refreshes it, so a long build with no sync in it will let the lease lapse, and a peer then prunes it without telling you.`;
2687
+ }
2629
2688
  }
2630
2689
  // ── Would this release leave finished work behind? (1.72.0) ──────────
2631
2690
  // Coordination in this engine had always been about files two sessions