klypix-mcp 1.67.2 → 1.69.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/README.md CHANGED
@@ -693,17 +693,22 @@ Read this section before you build on any of it.
693
693
  Every number here is measured on our own project brain. Nothing below is published, benchmarked or
694
694
  independently validated.
695
695
 
696
- - **Dogfood scale.** KLYPIX itself is built with its own brain: **1,645 cards and 1,521
696
+ - **Dogfood scale.** KLYPIX itself is built with its own brain: **2,479 cards and 2,018
697
697
  connections**, written by multiple concurrent agent sessions, receipts in the file. Current as of
698
- 2026-08-01.
698
+ 2026-08-13.
699
699
  - **Recall.** 73% of past decisions recovered with one search round, 55% brief-only, 0% cold.
700
700
  Caveat that travels with it: n=20, our own brain, self-authored questions, LLM-judged.
701
- - **Ranker.** recall@5 of the true source card went **15% 40%** across two upgrades (n=20 frozen
702
- human-paraphrase questions), measured with the optional on-device reranker enabled. The
703
- experiment that *regressed* contextual prefixes on short cards is recorded next to the wins.
701
+ - **Ranker.** With the production embedder (the eval harness was fixed 2026-08-10 it had been
702
+ measuring a vector space the product does not use): recall@5 **30%**, recall@10 35%, recall@20
703
+ 45%, MRR 0.22 of the true source card on n=20 frozen human-paraphrase questions. Lexical-only
704
+ scores 0% on the same set. The previously published "15% → 40% with the reranker" is **retired**:
705
+ re-measured validly, the reranker *reduced* recall@5 to 25% and now ships off by default. At n=20
706
+ every one of these percentages carries a ±20-point 95% confidence interval — treat them as
707
+ directional until the larger frozen set lands. The regressions are recorded next to the wins:
708
+ contextual prefixes on short cards, and the reranker itself.
704
709
  - **What we do not publish.** No download count: this package's own 24-hour auto-updater generates
705
710
  most of it, so it is not a user count. No adoption, team or customer figures. No brief-token
706
- figure — the last one was measured at ~600 cards and is stale at 1,645.
711
+ figure — the last one was measured at ~600 cards and is stale at 2,479.
707
712
  - **The eval harness is not in this repo.** It lives in the private KLYPIX desktop repository. The
708
713
  numbers above are ours to defend, not yours to reproduce from here — treat them accordingly.
709
714
 
@@ -10,6 +10,8 @@
10
10
  // npx klypix-mcp install # install / update the brain on this machine
11
11
  // npx klypix-mcp install --force # overwrite even a newer / dev-deployed brain
12
12
  // npx klypix-mcp install --codex-hooks # optional prompt/file awareness; Codex asks for trust
13
+ // npx klypix-mcp install --allow-untagged # acknowledge deploying an UNTAGGED source checkout
14
+ // # (dev deploy; also KLYPIX_MCP_ALLOW_UNTAGGED=1)
13
15
  //
14
16
  // Never-throws-silently: it's an explicit CLI, so it reports what it did and exits
15
17
  // non-zero on a real failure. Never wires a broken settings.json (refuse + restore).
@@ -17,7 +19,7 @@ import fs from 'fs';
17
19
  import os from 'os';
18
20
  import path from 'path';
19
21
  import crypto from 'crypto';
20
- import { spawn } from 'child_process';
22
+ import { execFileSync, spawn } from 'child_process';
21
23
  import { fileURLToPath } from 'url';
22
24
  import {
23
25
  connectCodexMcpServer,
@@ -29,8 +31,9 @@ import {
29
31
  codexPresenceHookStatus,
30
32
  mergeCodexPresenceHooks,
31
33
  } from '../src/codex-hooks.mjs';
32
- import { brainInstallDecision } from '../src/install-version.mjs';
34
+ import { brainInstallDecision, deploySourceDecision } from '../src/install-version.mjs';
33
35
  import { acquireInstallLockSync, releaseInstallLockSync } from '../src/install-lock.mjs';
36
+ import { collectRepoState } from '../src/repo-state.mjs';
34
37
 
35
38
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
39
  const PKG_ROOT = path.resolve(__dirname, '..');
@@ -45,6 +48,11 @@ const VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.join(PKG_R
45
48
  const FORCE = process.argv.includes('--force');
46
49
  const CODEX_HOOKS = process.argv.includes('--codex-hooks');
47
50
  const RUNTIME_ONLY = process.argv.includes('--runtime-only');
51
+ // Released-tag deploy-guard acknowledgement. Deliberately a SEPARATE axis from
52
+ // --force: --force is destination authority (overwrite what is installed),
53
+ // this is source authority (knowingly deploy an untagged working tree).
54
+ const ALLOW_UNTAGGED = process.argv.includes('--allow-untagged')
55
+ || process.env.KLYPIX_MCP_ALLOW_UNTAGGED === '1';
48
56
 
49
57
  const HOME = os.homedir();
50
58
  const CLAUDE_DIR = path.join(HOME, '.claude');
@@ -224,6 +232,52 @@ try {
224
232
  if (!RUNTIME_ONLY) reportCodex(wireCodex());
225
233
  process.exit(0);
226
234
  }
235
+
236
+ // ── Released-tag deploy guard (2026-08-14 bundle-currency incident) ──────
237
+ // A source checkout whose HEAD does not carry the release tag for its own
238
+ // package version is UNRELEASED code, yet this installer used to lay it
239
+ // down machine-globally with receipts claiming a clean npm delivery — the
240
+ // exact twin of the desktop near-miss that shipped this wave. Only
241
+ // PKG_ROOT's own git state is interrogated (never process.cwd(): the
242
+ // auto-update child runs the installer with the PROJECT as cwd), and only
243
+ // when PKG_ROOT itself contains a .git entry — an npm/npx tarball install
244
+ // has none and IS the released artifact, so it stays exempt; the registry
245
+ // channel is already tag-bound by publish.yml. The tag must point at HEAD
246
+ // (`git tag --points-at HEAD` semantics inside collectRepoState): the
247
+ // release tag names the exact evidence commit, so any non-HEAD comparison
248
+ // would certify code the tag never covered.
249
+ const checkout = exists(path.join(PKG_ROOT, '.git')) ? collectRepoState(PKG_ROOT) : null;
250
+ const sourceDecision = deploySourceDecision({ checkout, allowUntagged: ALLOW_UNTAGGED });
251
+ const checkoutLabel = `v${VERSION}, branch ${checkout?.branch || '(detached)'}, head ${checkout?.headShort || '?'}`;
252
+ if (sourceDecision.action === 'refuse') {
253
+ releaseInstallLockSync(installLock);
254
+ console.error(`✗ refusing to deploy an UNRELEASED source checkout machine-globally: ${checkoutLabel} — no release tag v${VERSION} at HEAD; no files were changed.`);
255
+ console.error(' Released installs come from the registry: npx -y klypix-mcp@latest install');
256
+ console.error(' To deliberately deploy this working tree (a dev deploy), acknowledge it: re-run with --allow-untagged or KLYPIX_MCP_ALLOW_UNTAGGED=1.');
257
+ console.error(' An acknowledged dev deploy is stamped dev-owned, so brain_doctor shows it and auto-update will not silently replace it.');
258
+ process.exit(1);
259
+ }
260
+ const untaggedSource = sourceDecision.source === 'untagged-working-tree';
261
+ // Deploy-time snapshot for ANY git-checkout deploy (tagged or acknowledged-
262
+ // untagged): the release tag certifies HEAD's bytes, not the working
263
+ // tree's, so uncommitted changes make even a TAGGED deploy differ from
264
+ // what was released — a clean `dirty:false` stamp would lie about exactly
265
+ // that (the receipt-honesty defect this wave exists to kill). An
266
+ // unreadable status degrades to false rather than inventing a DIRTY nag
267
+ // in every session brief. Tarball installs (checkout null) skip the spawn.
268
+ const sourceDirty = Boolean(checkout) && (() => {
269
+ try {
270
+ return execFileSync('git', ['status', '--porcelain'], {
271
+ cwd: PKG_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500,
272
+ }).trim().length > 0;
273
+ } catch { return false; }
274
+ })();
275
+ if (untaggedSource) {
276
+ console.log(`• untagged source deploy acknowledged: ${checkoutLabel}${sourceDirty ? ' (working tree DIRTY)' : ''} — receipts record a dev-owned install; a later released install needs --force.`);
277
+ } else if (sourceDirty) {
278
+ console.log(`⚠ release-tagged checkout has uncommitted changes (${checkoutLabel}) — deployed bytes differ from the release; stamping dirty:true so brain_doctor surfaces it.`);
279
+ }
280
+
227
281
  fs.mkdirSync(BRAIN_DIR, { recursive: true });
228
282
  // 1) runtime dependency CLOSURE (jszip+fractional-indexing for the hook/engine,
229
283
  // @modelcontextprotocol/sdk+zod for the local MCP server). Resolve each via
@@ -281,7 +335,7 @@ try {
281
335
  // canvas-view-app.html is the canvas_view MCP App UI — staged raw (an HTML
282
336
  // file must never get a JS-comment banner) beside the flat server, which
283
337
  // resolves it via its ./canvas-view-app.html candidate path.
284
- 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', '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']) {
338
+ 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']) {
285
339
  const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
286
340
  }
287
341
  for (const [src, dst] of [
@@ -344,19 +398,38 @@ try {
344
398
  // a crash between receipts remains recoverable because the next installer
345
399
  // compares both and keeps the highest valid Brain Core version.
346
400
  const installedAt = new Date().toISOString();
401
+ // Receipt honesty (2026-08-14): an acknowledged untagged source deploy is
402
+ // a DEV delivery — stamping it 'npm'/dirty:false made unreleased code
403
+ // byte-for-byte indistinguishable from a released install. 'dev' is
404
+ // existing receipt vocabulary (brainInstallDecision's dev-owned preserve,
405
+ // auto-update's dev skip, doctor's channel/dev/dirty rendering all consume
406
+ // it); sourceSha/branch are additive fields for post-hoc audit. dev:true
407
+ // goes in BOTH receipts because the runtime receipt commits first — a
408
+ // crash between the two writes must not leave a dev deploy unmarked.
347
409
  const runtime = {
348
410
  protocol: 1,
349
411
  version: VERSION,
350
412
  worker: 'klypix-mcp-worker.mjs',
351
- channel: 'npm',
413
+ channel: untaggedSource ? 'dev' : 'npm',
414
+ ...(untaggedSource ? { dev: true, sourceSha: checkout?.headShort || null, branch: checkout?.branch || null } : {}),
352
415
  installedAt,
353
416
  files: Object.fromEntries(staged.map(st => [st.dst, crypto.createHash('sha256').update(st.content).digest('hex')])),
354
417
  };
355
418
  const runtimePath = path.join(BRAIN_DIR, '.mcp-runtime.json');
356
419
  fs.writeFileSync(runtimePath + '.klypix-new', JSON.stringify(runtime, null, 2) + '\n', 'utf8');
357
420
  fs.renameSync(runtimePath + '.klypix-new', runtimePath);
421
+ // A tagged-but-DIRTY checkout keeps via:'npm' (the tag still names the
422
+ // payload identity, and dev:true would stop auto-update from healing the
423
+ // machine back to clean released bytes) but stamps dirty:true + the audit
424
+ // fields — doctor's DIRTY line and the hook's dirty nag both read the
425
+ // stamp. The clean released path stays byte-identical to pre-guard.
426
+ const versionStamp = untaggedSource
427
+ ? { brainVersion: VERSION, via: 'dev', dev: true, dirty: sourceDirty, sourceSha: checkout?.headShort || null, branch: checkout?.branch || null, installedAt }
428
+ : sourceDirty
429
+ ? { brainVersion: VERSION, via: 'npm', dirty: true, sourceSha: checkout?.headShort || null, branch: checkout?.branch || null, installedAt }
430
+ : { brainVersion: VERSION, via: 'npm', dirty: false, installedAt };
358
431
  const versionPath = path.join(BRAIN_DIR, '.brain-version.json');
359
- fs.writeFileSync(versionPath + '.klypix-new', JSON.stringify({ brainVersion: VERSION, via: 'npm', dirty: false, installedAt }, null, 2), 'utf8');
432
+ fs.writeFileSync(versionPath + '.klypix-new', JSON.stringify(versionStamp, null, 2), 'utf8');
360
433
  fs.renameSync(versionPath + '.klypix-new', versionPath);
361
434
 
362
435
  // 7) migrate THIS project's .mcp.json off npx onto the now-installed local bundle
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.67.2",
3
+ "version": "1.69.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",
@@ -83,7 +83,7 @@
83
83
  "bench": "node bin/klypix-mcp.mjs bench",
84
84
  "test:bench": "node test/bench.mjs",
85
85
  "pretest": "node test/publish-workflow.mjs",
86
- "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/context-gateway.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/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.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/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/a2a-smoke.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/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/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/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.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/presence-liveness.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/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
87
87
  "test:memory": "node test/memory-runtime.mjs",
88
88
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
89
89
  "runtime": "node bin/klypix-runtime.mjs"
@@ -49,6 +49,53 @@ export const MACHINE_ID = (() => {
49
49
  catch { return sha16(`${os.hostname?.() || 'unknown'}|${os.platform?.() || 'unknown'}`); }
50
50
  })();
51
51
 
52
+ // ── Dead-host sweep (2026-08-14 wave) ────────────────────────────────────────
53
+ // TTL pruning alone leaves a crashed host's rows visible for up to 10 minutes —
54
+ // long enough for a peer to "coordinate" with a ghost. When any session touches
55
+ // the lane, rows whose HOST process is provably dead are swept immediately.
56
+ // The probe is deliberately narrow, because a false sweep erases live presence:
57
+ // · only rows written on THIS machine (row.machine === MACHINE_ID; a pid from
58
+ // another machine or a cloud-relayed row lives in a foreign pid namespace),
59
+ // · only rows whose hostPid came from a host-declared env var
60
+ // (hostPidSource === 'env'). A ppid-guessed pid may be a short-lived shell
61
+ // wrapper — probing it would sweep a perfectly live session — so guessed
62
+ // pids are used for row correlation only, never for liveness,
63
+ // · only rows older than a grace window, so a row written moments ago by a
64
+ // process racing its own exit cannot flap.
65
+ // Anything the probe cannot decide falls back to the TTL rule — the sweep can
66
+ // only ever REMOVE provably-dead rows earlier, never keep rows longer.
67
+ // The grace deliberately EXCEEDS the MCP heartbeat interval (60s in
68
+ // mcp-presence.mjs — not imported here because that module imports this one):
69
+ // a session that is still writing heartbeats keeps its row's age under the
70
+ // grace, so even a WRONGLY-declared host pid (say, a stale KLYPIX_HOST_PID
71
+ // leaked from a shell profile) can never sweep a session that is actively
72
+ // alive. Only silent rows — the crash shape the sweep exists for — get probed.
73
+ export const DEAD_HOST_GRACE_MS = 90 * 1000;
74
+
75
+ // kill(pid, 0) is the portable liveness probe (works on Windows via
76
+ // OpenProcess). ESRCH ⇒ no such process; EPERM/EACCES ⇒ exists but owned by
77
+ // someone else ⇒ alive. Anything else — invalid input or an error code this
78
+ // table doesn't know — returns null ("cannot say"), NEVER false: an unproven
79
+ // death must fall back to TTL, because a false "dead" erases live presence.
80
+ export function isProcessAlive(pid) {
81
+ const n = Number(pid);
82
+ if (!Number.isInteger(n) || n <= 0) return null;
83
+ try { process.kill(n, 0); return true; }
84
+ catch (err) {
85
+ if (err?.code === 'ESRCH') return false;
86
+ if (err?.code === 'EPERM' || err?.code === 'EACCES') return true;
87
+ return null;
88
+ }
89
+ }
90
+
91
+ export function isDeadHostRow(session, now = Date.now(), probe = isProcessAlive) {
92
+ if (!session || session.via === 'cloud') return false;
93
+ if (String(session.hostPidSource || '') !== 'env') return false;
94
+ if (!session.machine || String(session.machine) !== String(MACHINE_ID)) return false;
95
+ if (now - Number(session.lastSeen || 0) < DEAD_HOST_GRACE_MS) return false;
96
+ return probe(session.hostPid) === false;
97
+ }
98
+
52
99
  // A lane write can legitimately FAIL: the lock is held and we give up rather than
53
100
  // clobber a peer's just-posted message. That path returned listActiveSessions(),
54
101
  // i.e. the exact same shape as a success — so a dropped heartbeat was
@@ -405,6 +452,9 @@ function pruneSessions(sessions, now) {
405
452
  const out = [];
406
453
  for (const session of (Array.isArray(sessions) ? sessions : [])) {
407
454
  if (!session?.id) continue;
455
+ // Dead-host sweep: a row whose host process is provably gone is removed on
456
+ // the next lane touch instead of lingering for the full TTL window.
457
+ if (isDeadHostRow(session, now)) continue;
408
458
  const hadChannels = session.channelSeen
409
459
  && typeof session.channelSeen === 'object'
410
460
  && !Array.isArray(session.channelSeen)
@@ -454,6 +504,7 @@ function normalizeDeliveryRecord(record) {
454
504
  ...(record?.acknowledgedActionId ? { acknowledgedActionId: String(record.acknowledgedActionId).slice(0, 160) } : {}),
455
505
  ...(record?.consumedActionId ? { consumedActionId: String(record.consumedActionId).slice(0, 160) } : {}),
456
506
  ...(record?.offerToken ? { offerToken: String(record.offerToken).slice(0, 160) } : {}),
507
+ ...(record?.consumedVia ? { consumedVia: String(record.consumedVia).slice(0, 40) } : {}),
457
508
  ...(record?.reason ? { reason: String(record.reason).slice(0, 120) } : {}),
458
509
  ...(record?.legacySeen ? { legacySeen: true } : {}),
459
510
  };
@@ -485,9 +536,16 @@ export function normalizeMessageDelivery(message, now = Date.now()) {
485
536
  next.deliveryVersion = MESSAGE_DELIVERY_VERSION;
486
537
  next.deliveries = [...byRecipient.values()];
487
538
  if (!Array.isArray(next.seen)) next.seen = [];
488
- // v2 retired on acknowledgement, which proved only model-context injection.
489
- // v3 must conservatively replay it until a token-bound consume receipt lands.
490
- if (sourceVersion < MESSAGE_DELIVERY_VERSION && next.retiredAt && !next.deadLetter) delete next.retiredAt;
539
+ // History is never rewritten (2026-08-13). The previous migration deleted
540
+ // retiredAt from v2-retired messages to "conservatively replay" them which
541
+ // resurrected up to a week of already-delivered notes, and immediately
542
+ // re-terminalized every one older than 24h as FAILED. That falsified
543
+ // delivery history in both directions on first post-upgrade touch. A v2
544
+ // retirement proved model-context injection; it stays retired, recorded as
545
+ // exactly that and no more.
546
+ if (sourceVersion < MESSAGE_DELIVERY_VERSION && next.retiredAt && !next.deadLetter && !next.retirement) {
547
+ next.retirement = { reason: 'v2-retired — model-context injection proven, explicit consumption unknown', at: Number(next.retiredAt) || now };
548
+ }
491
549
  return next;
492
550
  }
493
551
 
@@ -527,6 +585,7 @@ function setDeliveryState(message, sessionId, state, now, reason = null, actionI
527
585
  delete record.consumedAt;
528
586
  delete record.acknowledgedActionId;
529
587
  delete record.consumedActionId;
588
+ delete record.consumedVia;
530
589
  delete record.failedAt;
531
590
  delete record.reason;
532
591
  } else if (state === 'acknowledged') {
@@ -557,14 +616,29 @@ function terminalizeMessage(message, now, reason) {
557
616
  ...(Array.isArray(next.candidateIds) ? next.candidateIds : []),
558
617
  ...next.deliveries.map((entry) => entry.recipientId),
559
618
  ].map(recipientKey).filter(Boolean));
560
- let unconsumed = known.size === 0;
619
+ // Split by how far delivery actually got (2026-08-13). 'acknowledged' means
620
+ // the note was rendered into model context on two independent actions —
621
+ // expiring after that is NOT a delivery failure, and the old blanket
622
+ // 'failed' told the sender "no target consumed it" about a note the model
623
+ // saw repeatedly. Only recipients the note never reached (pending) or
624
+ // reached exactly once without confirmation (offered) fail, each with a
625
+ // reason that says which. A message whose every recipient at least
626
+ // acknowledged retires as delivered-unconfirmed instead of dead-lettering.
627
+ let failures = 0, reachedUnconsumed = 0;
561
628
  for (const recipientId of known) {
562
- if (messageDeliveryState(next, recipientId) === 'consumed') continue;
563
- unconsumed = true;
564
- setDeliveryState(next, recipientId, 'failed', now, reason);
629
+ const state = messageDeliveryState(next, recipientId);
630
+ if (state === 'consumed') continue;
631
+ if (state === 'acknowledged') { reachedUnconsumed++; continue; }
632
+ failures++;
633
+ setDeliveryState(next, recipientId, 'failed', now,
634
+ state === 'pending' ? `${reason} (never delivered)` : `${reason} (offered once, unconfirmed)`);
635
+ }
636
+ if (failures || known.size === 0) {
637
+ next.deadLetter = { state: 'failed', reason, at: now };
638
+ } else {
639
+ next.retiredAt = now;
640
+ if (reachedUnconsumed) next.retirement = { reason: `${reason} — after acknowledgement (delivered, consumption unconfirmed)`, at: now };
565
641
  }
566
- if (unconsumed) next.deadLetter = { state: 'failed', reason, at: now };
567
- else next.retiredAt = now;
568
642
  return next;
569
643
  }
570
644
 
@@ -667,6 +741,7 @@ export function upsertSession({
667
741
  transportStatus = null,
668
742
  cwd = null,
669
743
  hostPid = null,
744
+ hostPidSource = null,
670
745
  logicalSessionId = null,
671
746
  identitySource = null,
672
747
  aliases,
@@ -758,6 +833,16 @@ export function upsertSession({
758
833
  // Host-process correlation is diagnostic topology only. A desktop parent
759
834
  // can own many chats, so hostPid must never imply identity or deduping.
760
835
  hostPid: Number(hostPid) || previous.hostPid || null,
836
+ // Provenance of the pid decides what it may be used for: 'env' (declared
837
+ // by the host itself, safe to liveness-probe) vs 'ppid' (a best-effort
838
+ // parent guess, correlation only). ADDITIVE — absent means unknown, and
839
+ // unknown is never probed. Kept aligned with the pid it describes: a new
840
+ // pid without a declared source resets the field rather than inheriting
841
+ // the previous pid's provenance.
842
+ hostPidSource: Number(hostPid)
843
+ ? (hostPidSource ? String(hostPidSource).slice(0, 16)
844
+ : (Number(hostPid) === Number(previous.hostPid) ? previous.hostPidSource || null : null))
845
+ : previous.hostPidSource || null,
761
846
  logicalSessionId: recipientKey(logicalSessionId || previous.logicalSessionId || '') || null,
762
847
  identitySource: String(identitySource || previous.identitySource || 'provisional').slice(0, 80),
763
848
  aliases: normalizeAliases(aliases === undefined ? previous.aliases : [...(previous.aliases || []), ...(aliases || [])], id),
@@ -1019,8 +1104,15 @@ export function endSession({ brainPath, id, home, now = Date.now(), expectedPid
1019
1104
  endedSessions.push({ id: recipientKey(row?.logicalSessionId || row?.id || id), endedAt: now,
1020
1105
  aliases: normalizeAliases(identities, row?.logicalSessionId || row?.id || id) });
1021
1106
  endedSessions = endedSessions.slice(-ENDED_SESSION_CAP);
1107
+ // The sweep also removes transport twins that never completed id adoption:
1108
+ // an mcp-<pid> row whose logicalSessionId names the ended identity IS this
1109
+ // session and must not linger until its own TTL. Rotation stays safe — a
1110
+ // NEW conversation created after SessionEnd(A) carries its OWN id as
1111
+ // logicalSessionId (rotateEndedSessionIdentity sets logicalSessionId=toKey
1112
+ // and never aliases A), so it can never match A's identity set here.
1022
1113
  const kept = sessions.filter((session) => !identities.includes(recipientKey(session.id))
1023
- && !normalizeAliases(session.aliases).some((alias) => identities.includes(alias)));
1114
+ && !normalizeAliases(session.aliases).some((alias) => identities.includes(alias))
1115
+ && !(session.logicalSessionId && identities.includes(recipientKey(session.logicalSessionId))));
1024
1116
  fs.mkdirSync(path.dirname(laneFile), { recursive: true });
1025
1117
  writeLaneFileAtomic(laneFile, JSON.stringify({
1026
1118
  ...data,
@@ -1047,6 +1139,7 @@ export function rotateEndedSessionIdentity({
1047
1139
  surface = 'mcp',
1048
1140
  cwd = null,
1049
1141
  hostPid = null,
1142
+ hostPidSource = null,
1050
1143
  identitySource = 'mcp-request',
1051
1144
  home,
1052
1145
  now = Date.now(),
@@ -1105,6 +1198,8 @@ export function rotateEndedSessionIdentity({
1105
1198
  deliveryReachability: sessionDeliveryReachability({ channels: Object.keys(channelSeen), transport }),
1106
1199
  cwd: cwd ? path.resolve(cwd) : (existing?.cwd || path.dirname(brainPath)),
1107
1200
  hostPid: Number(hostPid) || existing?.hostPid || null,
1201
+ hostPidSource: (Number(hostPid) && hostPidSource) ? String(hostPidSource).slice(0, 16)
1202
+ : existing?.hostPidSource || null,
1108
1203
  logicalSessionId: toKey,
1109
1204
  identitySource: String(identitySource || 'mcp-request').slice(0, 80),
1110
1205
  // Never attach A as an alias: targeting A after its close must continue
@@ -1148,6 +1243,7 @@ export function switchMcpSessionIdentity({
1148
1243
  surface = 'mcp',
1149
1244
  cwd = null,
1150
1245
  hostPid = null,
1246
+ hostPidSource = null,
1151
1247
  identitySource = 'mcp-request',
1152
1248
  home,
1153
1249
  now = Date.now(),
@@ -1246,6 +1342,8 @@ export function switchMcpSessionIdentity({
1246
1342
  deliveryReachability: sessionDeliveryReachability({ channels: Object.keys(channelSeen), transport }),
1247
1343
  cwd: cwd ? path.resolve(cwd) : (existing?.cwd || path.dirname(brainPath)),
1248
1344
  hostPid: Number(hostPid) || existing?.hostPid || null,
1345
+ hostPidSource: (Number(hostPid) && hostPidSource) ? String(hostPidSource).slice(0, 16)
1346
+ : existing?.hostPidSource || null,
1249
1347
  logicalSessionId: toKey,
1250
1348
  identitySource: String(identitySource || 'mcp-request').slice(0, 80),
1251
1349
  // Even a stale/pre-fix B row must not retain A as a targeting alias.
@@ -1537,9 +1635,13 @@ export function receiveMessages({
1537
1635
  const record = messageDeliveryRecord(message, sessionId);
1538
1636
  return !isTerminalMessage(message)
1539
1637
  && message.from !== sessionId
1540
- && (state === 'pending' || state === 'offered' || state === 'acknowledged')
1638
+ // 'acknowledged' re-renders ONLY when there is no action-identity
1639
+ // evidence of a third action (no actionId on this call, or a legacy
1640
+ // record acknowledged without one). With evidence, the lease pass
1641
+ // below retires it as consumed instead of injecting it a third time.
1642
+ && (state === 'pending' || state === 'offered'
1643
+ || (state === 'acknowledged' && (!actionId || !record?.acknowledgedActionId)))
1541
1644
  && !(state === 'offered' && actionId && record?.offeredActionId === String(actionId))
1542
- && !(state === 'acknowledged' && actionId && record?.acknowledgedActionId === String(actionId))
1543
1645
  && messageTargetsSession(message, me, sessionId, sessions);
1544
1646
  }).sort((left, right) => {
1545
1647
  // Never let an acknowledged-but-not-yet-consumed replay starve a fresh
@@ -1577,6 +1679,30 @@ export function receiveMessages({
1577
1679
  setDeliveryState(message, sessionId, 'acknowledged', now, null, actionId);
1578
1680
  }
1579
1681
  }
1682
+ // Lease auto-consume (2026-08-13). An acknowledged note was rendered into
1683
+ // model context on TWO independent actions; when a THIRD independent
1684
+ // action arrives, the model has demonstrably moved on with the note in
1685
+ // hand. Retire it as consumed (consumedVia 'auto-lease') instead of
1686
+ // replaying it every action for 24h and then dead-lettering a note the
1687
+ // model saw repeatedly as "failed" — the old design guaranteed a steady
1688
+ // background rate of false-failure receipts from every recipient that
1689
+ // cannot (hook-only lanes) or does not copy offer tokens back. The
1690
+ // explicit brain_message_receipt remains the only path that can record
1691
+ // the stronger claim (consumedVia 'receipt' — "I acted on it").
1692
+ // Requires a real, DIFFERENT actionId: with no action identity there is
1693
+ // no evidence of a third action, so behavior stays replay-until-receipt.
1694
+ if (actionId) {
1695
+ for (const message of messages) {
1696
+ if (isTerminalMessage(message) || message.from === sessionId) continue;
1697
+ const record = messageDeliveryRecord(message, sessionId);
1698
+ if (!record || record.state !== 'acknowledged') continue;
1699
+ if (!record.acknowledgedActionId || record.acknowledgedActionId === String(actionId)) continue;
1700
+ if (!messageTargetsSession(message, me, sessionId, sessions)) continue;
1701
+ setDeliveryState(message, sessionId, 'consumed', now, null, actionId);
1702
+ record.consumedVia = 'auto-lease';
1703
+ retireFullyConsumed(message, now);
1704
+ }
1705
+ }
1580
1706
  // Persist migration/expiry/dead-letter changes even when the inbox is empty.
1581
1707
  fs.mkdirSync(path.dirname(laneFile), { recursive: true });
1582
1708
  writeLaneFileAtomic(laneFile, JSON.stringify({ ...data, sessions, messages }));
@@ -1684,6 +1810,15 @@ export function consumeMessageReceipt({
1684
1810
  return receiptResult(false, false, 'rejected', 'offer-token-mismatch', wantedMessageId, recipientId);
1685
1811
  }
1686
1812
  if (state === 'consumed') {
1813
+ // An auto-leased consumption (the third-action lease in receiveMessages)
1814
+ // proves the model moved on with the note in context; an explicit
1815
+ // receipt is the STRONGER claim — "I acted on it". Record the upgrade.
1816
+ if (record.consumedVia !== 'receipt') {
1817
+ record.consumedVia = 'receipt';
1818
+ fs.mkdirSync(path.dirname(laneFile), { recursive: true });
1819
+ writeLaneFileAtomic(laneFile, JSON.stringify({ ...data, sessions, messages }));
1820
+ return receiptResult(true, true, 'consumed', null, wantedMessageId, recipientId);
1821
+ }
1687
1822
  return receiptResult(true, false, 'consumed', null, wantedMessageId, recipientId);
1688
1823
  }
1689
1824
  if (state === 'offered') {
@@ -1698,6 +1833,8 @@ export function consumeMessageReceipt({
1698
1833
  return receiptResult(false, false, 'rejected', 'delivery-not-acknowledged', wantedMessageId, recipientId);
1699
1834
  }
1700
1835
  setDeliveryState(message, recipientId, 'consumed', now, null, actionId);
1836
+ const consumedRecord = messageDeliveryRecord(message, recipientId);
1837
+ if (consumedRecord) consumedRecord.consumedVia = 'receipt';
1701
1838
  retireFullyConsumed(message, now);
1702
1839
  fs.mkdirSync(path.dirname(laneFile), { recursive: true });
1703
1840
  writeLaneFileAtomic(laneFile, JSON.stringify({ ...data, sessions, messages }));
@@ -1832,39 +1969,127 @@ const clientLabel = (session) => {
1832
1969
  return client.replace(/(^|[-_ ])([a-z])/g, (_m, prefix, letter) => `${prefix}${letter.toUpperCase()}`);
1833
1970
  };
1834
1971
 
1972
+ // 'mcp'/'unknown' are placeholders (getClientVersion() is optional) — only a
1973
+ // concretely known client name may VETO a fold; a placeholder stays compatible.
1974
+ const specificClient = (client) => {
1975
+ const value = String(client || '').toLowerCase();
1976
+ return value && value !== 'mcp' && value !== 'unknown' ? value : null;
1977
+ };
1978
+
1979
+ // Group live lane rows into LOGICAL sessions for rendering. One conversation
1980
+ // can hold multiple transport rows when id adoption failed mid-flight (the
1981
+ // lifecycle id plus an mcp-<pid> provisional). Rendering each row as its own
1982
+ // session double-counts peers, so the footer merges — but merge order matters:
1983
+ // 1. exact logical identity (logicalSessionId || id) — always safe;
1984
+ // 2. pid-assisted fold, ONLY for an mcp-only row with no logical identity
1985
+ // that shares machine + hostPid + a compatible client with EXACTLY ONE
1986
+ // identity-anchored session. Codex runs many threads below one desktop
1987
+ // pid, so any ambiguity fails open as separate sessions (hiding a real
1988
+ // peer is worse than showing a twin twice — 2026-07-30 hardening).
1989
+ // This is a RENDER grouping only: lane rows, identity, message audiences and
1990
+ // conflict detection are untouched.
1991
+ export function mergePresenceRows(sessions) {
1992
+ const rows = (Array.isArray(sessions) ? sessions : []).filter((row) => row?.id);
1993
+ const keyOf = (row) => (recipientKey(row.logicalSessionId) || recipientKey(row.id)).toLowerCase();
1994
+ const groups = new Map();
1995
+ for (const row of rows) {
1996
+ const key = keyOf(row);
1997
+ if (!groups.has(key)) groups.set(key, []);
1998
+ groups.get(key).push(row);
1999
+ }
2000
+ const isAnchored = (group) => group.some((row) => recipientKey(row.logicalSessionId)
2001
+ || (Array.isArray(row.channels) && row.channels.includes('lifecycle')));
2002
+ const isFoldableOrphan = (group) => group.length === 1
2003
+ && group.every((row) => !recipientKey(row.logicalSessionId)
2004
+ && row.via !== 'cloud'
2005
+ && Number(row.hostPid) > 0
2006
+ && row.machine
2007
+ && Array.isArray(row.channels) && row.channels.length
2008
+ && row.channels.every((channel) => channel === 'mcp'));
2009
+ const anchoredKeys = [...groups.keys()].filter((key) => isAnchored(groups.get(key)));
2010
+ for (const [key, group] of [...groups.entries()]) {
2011
+ if (!isFoldableOrphan(group)) continue;
2012
+ const orphan = group[0];
2013
+ const candidates = anchoredKeys.filter((anchorKey) => groups.get(anchorKey)?.some((row) => row.machine
2014
+ && String(row.machine) === String(orphan.machine)
2015
+ && Number(row.hostPid) === Number(orphan.hostPid)
2016
+ && (!specificClient(row.client) || !specificClient(orphan.client)
2017
+ || specificClient(row.client) === specificClient(orphan.client))));
2018
+ if (candidates.length !== 1) continue; // ambiguous or none → fail open
2019
+ groups.get(candidates[0]).push(orphan);
2020
+ groups.delete(key);
2021
+ }
2022
+ return [...groups.values()].map((groupRows) => {
2023
+ const primary = groupRows.find((row) => Array.isArray(row.channels) && row.channels.includes('lifecycle'))
2024
+ || groupRows.find((row) => recipientKey(row.logicalSessionId))
2025
+ || [...groupRows].sort((a, b) => Number(b.lastSeen || 0) - Number(a.lastSeen || 0))[0];
2026
+ // The freshest declared intent wins across the group's rows — a brain_sync
2027
+ // scope declared on the mcp twin must not vanish behind a silent lifecycle
2028
+ // row (the twin's channels/scope count TOWARD the session, never hidden).
2029
+ const intentRow = [...groupRows]
2030
+ .filter((row) => String(row.intent || '').trim())
2031
+ .sort((a, b) => Number(b.intentAt || b.lastSeen || 0) - Number(a.intentAt || a.lastSeen || 0))[0] || primary;
2032
+ return {
2033
+ primary,
2034
+ rows: groupRows,
2035
+ channels: [...new Set(groupRows.flatMap((row) => Array.isArray(row.channels) ? row.channels : []))],
2036
+ lastSeen: Math.max(...groupRows.map((row) => Number(row.lastSeen || 0)), 0),
2037
+ intent: String(intentRow.intent || ''),
2038
+ intentAt: intentRow.intentAt || null,
2039
+ branch: primary.branch || groupRows.map((row) => row.branch).find(Boolean) || null,
2040
+ };
2041
+ });
2042
+ }
2043
+
1835
2044
  export function formatPresenceMessage(sessions, selfId, { includeSolo = false, now = Date.now() } = {}) {
1836
2045
  const active = Array.isArray(sessions) ? sessions : [];
1837
- const others = active.filter((session) => session.id !== selfId);
2046
+ const merged = mergePresenceRows(active);
2047
+ const selfKey = recipientKey(selfId);
2048
+ const isSelfGroup = (group) => group.rows.some((row) => recipientKey(row.id) === selfKey
2049
+ || recipientKey(row.logicalSessionId) === selfKey
2050
+ || normalizeAliases(row.aliases).includes(selfKey));
2051
+ const others = merged.filter((group) => !isSelfGroup(group));
1838
2052
  if (!includeSolo && !others.length) return '';
1839
2053
 
1840
2054
  const counts = new Map();
1841
- for (const session of active) {
1842
- const label = clientLabel(session);
2055
+ for (const group of merged) {
2056
+ const label = clientLabel(group.primary);
1843
2057
  counts.set(label, (counts.get(label) || 0) + 1);
1844
2058
  }
1845
2059
  const mix = [...counts.entries()].map(([label, count]) => `${label} ${count}`).join(', ');
2060
+ // When transports outnumber logical sessions, say so — folding a twin must
2061
+ // never silently understate how many live connections the lane holds.
2062
+ const connectionNote = active.length > merged.length
2063
+ ? `; ${active.length} connections` : '';
1846
2064
  const lines = [
1847
- `KLYPIX session awareness: ${active.length} active session${active.length === 1 ? '' : 's'} on this project (${mix || 'none'}); ${others.length} other${others.length === 1 ? '' : 's'} besides this chat.`,
2065
+ `KLYPIX session awareness: ${merged.length} active session${merged.length === 1 ? '' : 's'} on this project (${mix || 'none'}${connectionNote}); ${others.length} other${others.length === 1 ? '' : 's'} besides this chat.`,
1848
2066
  ];
1849
2067
  if (!others.length) {
1850
2068
  lines.push('Saved/recent chat rows are history, not active sessions; a session counts only while an authorized MCP connection or lifecycle adapter has heartbeated in the last 10 minutes.');
1851
2069
  return lines.join('\n');
1852
2070
  }
1853
2071
  lines.push('Other active sessions:');
1854
- for (const session of others.slice(0, 8)) {
1855
- const ageMin = Math.max(0, Math.round((now - Number(session.lastSeen || now)) / 60_000));
2072
+ // Uniqueness is computed over the merged primaries: UUIDv7 ids started in the
2073
+ // same window share a long time prefix, so each shown prefix grows (git
2074
+ // short-hash style, floor 8) until it names exactly one session.
2075
+ const prefixRows = merged.map((group) => group.primary);
2076
+ for (const group of others.slice(0, 8)) {
2077
+ const session = group.primary;
2078
+ const ageMin = Math.max(0, Math.round((now - Number(group.lastSeen || now)) / 60_000));
1856
2079
  // A heartbeat refreshes lastSeen while carrying an old intent forward — show
1857
2080
  // the INTENT's own age when it meaningfully lags the heartbeat, so a
1858
2081
  // 100-minute-old task line can never read as "what they're doing right now".
1859
- const intentAgeMin = session.intentAt ? Math.max(0, Math.round((now - Number(session.intentAt)) / 60_000)) : null;
2082
+ const intentAgeMin = group.intentAt ? Math.max(0, Math.round((now - Number(group.intentAt)) / 60_000)) : null;
1860
2083
  const intentAge = intentAgeMin !== null && intentAgeMin - ageMin > 3 ? ` (intent set ${intentAgeMin}m ago)` : '';
1861
2084
  const details = [
1862
2085
  clientLabel(session),
1863
- session.branch ? `branch ${session.branch}` : null,
1864
- session.intent ? `"${String(session.intent).slice(0, 90)}"${intentAge}` : null,
2086
+ group.rows.length > 1 ? `${group.rows.length} connections` : null,
2087
+ group.branch ? `branch ${group.branch}` : null,
2088
+ group.intent ? `"${String(group.intent).slice(0, 90)}"${intentAge}` : null,
1865
2089
  `${ageMin}m ago`,
1866
2090
  ].filter(Boolean);
1867
- lines.push(`- ${String(session.id).slice(0, 8)}: ${details.join(' | ')}`);
2091
+ const shortId = shortestUniqueSessionPrefix(prefixRows, session.id) || String(session.id).slice(0, 8);
2092
+ lines.push(`- ${shortId}: ${details.join(' | ')}`);
1868
2093
  }
1869
2094
  // v1.32.0 law: a truncated list must never render as a complete one.
1870
2095
  if (others.length > 8) lines.push(`- …and ${others.length - 8} more live session(s) not listed — brain_doctor shows all.`);