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 +11 -6
- package/bin/klypix-install.mjs +78 -5
- package/package.json +2 -2
- package/src/agent-presence.mjs +247 -22
- package/src/brain-doctor.mjs +66 -4
- package/src/codex-brain-hook.mjs +27 -5
- package/src/global-brain-hook.mjs +221 -35
- package/src/install-version.mjs +32 -1
- package/src/klypix-core.mjs +25 -9
- package/src/klypix-format.mjs +18 -2
- package/src/mcp-presence.mjs +102 -12
- package/src/mcp-supervisor.mjs +4 -0
- package/src/repo-state.mjs +251 -0
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: **
|
|
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-
|
|
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.**
|
|
702
|
-
|
|
703
|
-
|
|
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
|
|
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
|
|
package/bin/klypix-install.mjs
CHANGED
|
@@ -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(
|
|
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.
|
|
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"
|
package/src/agent-presence.mjs
CHANGED
|
@@ -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
|
-
//
|
|
489
|
-
//
|
|
490
|
-
|
|
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
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1842
|
-
const label = clientLabel(
|
|
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: ${
|
|
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
|
-
|
|
1855
|
-
|
|
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 =
|
|
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
|
-
|
|
1864
|
-
|
|
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
|
-
|
|
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.`);
|