klypix-mcp 1.68.0 → 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/bin/klypix-install.mjs +78 -5
- package/package.json +2 -2
- package/src/agent-presence.mjs +173 -11
- package/src/brain-doctor.mjs +66 -4
- package/src/codex-brain-hook.mjs +27 -5
- package/src/global-brain-hook.mjs +63 -2
- package/src/install-version.mjs +32 -1
- package/src/mcp-presence.mjs +94 -9
- package/src/mcp-supervisor.mjs +4 -0
- package/src/repo-state.mjs +251 -0
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/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",
|
|
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)
|
|
@@ -691,6 +741,7 @@ export function upsertSession({
|
|
|
691
741
|
transportStatus = null,
|
|
692
742
|
cwd = null,
|
|
693
743
|
hostPid = null,
|
|
744
|
+
hostPidSource = null,
|
|
694
745
|
logicalSessionId = null,
|
|
695
746
|
identitySource = null,
|
|
696
747
|
aliases,
|
|
@@ -782,6 +833,16 @@ export function upsertSession({
|
|
|
782
833
|
// Host-process correlation is diagnostic topology only. A desktop parent
|
|
783
834
|
// can own many chats, so hostPid must never imply identity or deduping.
|
|
784
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,
|
|
785
846
|
logicalSessionId: recipientKey(logicalSessionId || previous.logicalSessionId || '') || null,
|
|
786
847
|
identitySource: String(identitySource || previous.identitySource || 'provisional').slice(0, 80),
|
|
787
848
|
aliases: normalizeAliases(aliases === undefined ? previous.aliases : [...(previous.aliases || []), ...(aliases || [])], id),
|
|
@@ -1043,8 +1104,15 @@ export function endSession({ brainPath, id, home, now = Date.now(), expectedPid
|
|
|
1043
1104
|
endedSessions.push({ id: recipientKey(row?.logicalSessionId || row?.id || id), endedAt: now,
|
|
1044
1105
|
aliases: normalizeAliases(identities, row?.logicalSessionId || row?.id || id) });
|
|
1045
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.
|
|
1046
1113
|
const kept = sessions.filter((session) => !identities.includes(recipientKey(session.id))
|
|
1047
|
-
&& !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))));
|
|
1048
1116
|
fs.mkdirSync(path.dirname(laneFile), { recursive: true });
|
|
1049
1117
|
writeLaneFileAtomic(laneFile, JSON.stringify({
|
|
1050
1118
|
...data,
|
|
@@ -1071,6 +1139,7 @@ export function rotateEndedSessionIdentity({
|
|
|
1071
1139
|
surface = 'mcp',
|
|
1072
1140
|
cwd = null,
|
|
1073
1141
|
hostPid = null,
|
|
1142
|
+
hostPidSource = null,
|
|
1074
1143
|
identitySource = 'mcp-request',
|
|
1075
1144
|
home,
|
|
1076
1145
|
now = Date.now(),
|
|
@@ -1129,6 +1198,8 @@ export function rotateEndedSessionIdentity({
|
|
|
1129
1198
|
deliveryReachability: sessionDeliveryReachability({ channels: Object.keys(channelSeen), transport }),
|
|
1130
1199
|
cwd: cwd ? path.resolve(cwd) : (existing?.cwd || path.dirname(brainPath)),
|
|
1131
1200
|
hostPid: Number(hostPid) || existing?.hostPid || null,
|
|
1201
|
+
hostPidSource: (Number(hostPid) && hostPidSource) ? String(hostPidSource).slice(0, 16)
|
|
1202
|
+
: existing?.hostPidSource || null,
|
|
1132
1203
|
logicalSessionId: toKey,
|
|
1133
1204
|
identitySource: String(identitySource || 'mcp-request').slice(0, 80),
|
|
1134
1205
|
// Never attach A as an alias: targeting A after its close must continue
|
|
@@ -1172,6 +1243,7 @@ export function switchMcpSessionIdentity({
|
|
|
1172
1243
|
surface = 'mcp',
|
|
1173
1244
|
cwd = null,
|
|
1174
1245
|
hostPid = null,
|
|
1246
|
+
hostPidSource = null,
|
|
1175
1247
|
identitySource = 'mcp-request',
|
|
1176
1248
|
home,
|
|
1177
1249
|
now = Date.now(),
|
|
@@ -1270,6 +1342,8 @@ export function switchMcpSessionIdentity({
|
|
|
1270
1342
|
deliveryReachability: sessionDeliveryReachability({ channels: Object.keys(channelSeen), transport }),
|
|
1271
1343
|
cwd: cwd ? path.resolve(cwd) : (existing?.cwd || path.dirname(brainPath)),
|
|
1272
1344
|
hostPid: Number(hostPid) || existing?.hostPid || null,
|
|
1345
|
+
hostPidSource: (Number(hostPid) && hostPidSource) ? String(hostPidSource).slice(0, 16)
|
|
1346
|
+
: existing?.hostPidSource || null,
|
|
1273
1347
|
logicalSessionId: toKey,
|
|
1274
1348
|
identitySource: String(identitySource || 'mcp-request').slice(0, 80),
|
|
1275
1349
|
// Even a stale/pre-fix B row must not retain A as a targeting alias.
|
|
@@ -1895,39 +1969,127 @@ const clientLabel = (session) => {
|
|
|
1895
1969
|
return client.replace(/(^|[-_ ])([a-z])/g, (_m, prefix, letter) => `${prefix}${letter.toUpperCase()}`);
|
|
1896
1970
|
};
|
|
1897
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
|
+
|
|
1898
2044
|
export function formatPresenceMessage(sessions, selfId, { includeSolo = false, now = Date.now() } = {}) {
|
|
1899
2045
|
const active = Array.isArray(sessions) ? sessions : [];
|
|
1900
|
-
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));
|
|
1901
2052
|
if (!includeSolo && !others.length) return '';
|
|
1902
2053
|
|
|
1903
2054
|
const counts = new Map();
|
|
1904
|
-
for (const
|
|
1905
|
-
const label = clientLabel(
|
|
2055
|
+
for (const group of merged) {
|
|
2056
|
+
const label = clientLabel(group.primary);
|
|
1906
2057
|
counts.set(label, (counts.get(label) || 0) + 1);
|
|
1907
2058
|
}
|
|
1908
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` : '';
|
|
1909
2064
|
const lines = [
|
|
1910
|
-
`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.`,
|
|
1911
2066
|
];
|
|
1912
2067
|
if (!others.length) {
|
|
1913
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.');
|
|
1914
2069
|
return lines.join('\n');
|
|
1915
2070
|
}
|
|
1916
2071
|
lines.push('Other active sessions:');
|
|
1917
|
-
|
|
1918
|
-
|
|
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));
|
|
1919
2079
|
// A heartbeat refreshes lastSeen while carrying an old intent forward — show
|
|
1920
2080
|
// the INTENT's own age when it meaningfully lags the heartbeat, so a
|
|
1921
2081
|
// 100-minute-old task line can never read as "what they're doing right now".
|
|
1922
|
-
const intentAgeMin =
|
|
2082
|
+
const intentAgeMin = group.intentAt ? Math.max(0, Math.round((now - Number(group.intentAt)) / 60_000)) : null;
|
|
1923
2083
|
const intentAge = intentAgeMin !== null && intentAgeMin - ageMin > 3 ? ` (intent set ${intentAgeMin}m ago)` : '';
|
|
1924
2084
|
const details = [
|
|
1925
2085
|
clientLabel(session),
|
|
1926
|
-
|
|
1927
|
-
|
|
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,
|
|
1928
2089
|
`${ageMin}m ago`,
|
|
1929
2090
|
].filter(Boolean);
|
|
1930
|
-
|
|
2091
|
+
const shortId = shortestUniqueSessionPrefix(prefixRows, session.id) || String(session.id).slice(0, 8);
|
|
2092
|
+
lines.push(`- ${shortId}: ${details.join(' | ')}`);
|
|
1931
2093
|
}
|
|
1932
2094
|
// v1.32.0 law: a truncated list must never render as a complete one.
|
|
1933
2095
|
if (others.length > 8) lines.push(`- …and ${others.length - 8} more live session(s) not listed — brain_doctor shows all.`);
|
package/src/brain-doctor.mjs
CHANGED
|
@@ -43,6 +43,19 @@ let gitCaptureLib = null;
|
|
|
43
43
|
try { gitCaptureLib = await import('./git-capture-install.mjs'); } catch { gitCaptureLib = null; }
|
|
44
44
|
let historyLib = null;
|
|
45
45
|
try { historyLib = await import('./brain-history.mjs'); } catch { historyLib = null; }
|
|
46
|
+
// repo-state powers the advisory CHECKOUT line (released-tag visibility). Same
|
|
47
|
+
// failure-tolerant idiom: a half-updated flat bundle that predates the module
|
|
48
|
+
// must degrade to omitting the line, never kill the doctor.
|
|
49
|
+
let repoStateLib = null;
|
|
50
|
+
try { repoStateLib = await import('./repo-state.mjs'); } catch { repoStateLib = null; }
|
|
51
|
+
// agent-presence is the CANONICAL owner of the session-liveness rule (freshness
|
|
52
|
+
// windows + dead-host sweep). The doctor deliberately keeps reading lane files
|
|
53
|
+
// directly so it can diagnose a broken bundle, so this import is failure-
|
|
54
|
+
// tolerant too: on a bundle without it, the local fallback literals below keep
|
|
55
|
+
// the doctor alive — but a healthy bundle can no longer drift from the engine
|
|
56
|
+
// (the exact drift that once produced three different live-session counts).
|
|
57
|
+
let presenceLib = null;
|
|
58
|
+
try { presenceLib = await import('./agent-presence.mjs'); } catch { presenceLib = null; }
|
|
46
59
|
|
|
47
60
|
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
48
61
|
|
|
@@ -56,7 +69,12 @@ const cmpSemver = (a, b) => { const pa = String(a || '').split('.').map(n => par
|
|
|
56
69
|
|
|
57
70
|
const HOOK_MARK = 'global-brain-hook';
|
|
58
71
|
const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse'];
|
|
59
|
-
|
|
72
|
+
// Liveness windows imported from the canonical rule; literals are the LAST
|
|
73
|
+
// RESORT for a bundle whose agent-presence predates the exports.
|
|
74
|
+
const SESSION_FRESH_MS = Number(presenceLib?.SESSION_FRESH_MS) > 0
|
|
75
|
+
? Number(presenceLib.SESSION_FRESH_MS) : 10 * 60 * 1000;
|
|
76
|
+
const MCP_SESSION_FRESH_MS = Number(presenceLib?.MCP_SESSION_FRESH_MS) > 0
|
|
77
|
+
? Number(presenceLib.MCP_SESSION_FRESH_MS) : 3 * 60 * 1000;
|
|
60
78
|
|
|
61
79
|
// ── VERSION layer ────────────────────────────────────────────────────────────
|
|
62
80
|
function inspectVersion(brainDir) {
|
|
@@ -256,8 +274,11 @@ function inspectSupervisors(brainDir, baked) {
|
|
|
256
274
|
// Channel-aware liveness, mirroring agent-presence.mjs: an mcp-channel heartbeat
|
|
257
275
|
// is dead after 3 minutes even though the flat 10-minute window keeps the row —
|
|
258
276
|
// without this the hook, brain_sync, and doctor reported three different counts.
|
|
259
|
-
|
|
277
|
+
// (Both windows are now imported from agent-presence at the top of this file.)
|
|
260
278
|
const sessionLiveness = (s, now) => {
|
|
279
|
+
// Dead-host parity: a row every WRITER would sweep on its next lane touch
|
|
280
|
+
// must not render as live here. Guarded — an old bundle simply skips it.
|
|
281
|
+
if (typeof presenceLib?.isDeadHostRow === 'function' && presenceLib.isDeadHostRow(s, now)) return null;
|
|
261
282
|
const channelSeen = (s?.channelSeen && typeof s.channelSeen === 'object' && !Array.isArray(s.channelSeen)) ? s.channelSeen : null;
|
|
262
283
|
if (channelSeen && Object.keys(channelSeen).length) {
|
|
263
284
|
const fresh = Object.entries(channelSeen)
|
|
@@ -468,6 +489,31 @@ export function inspect(opts = {}) {
|
|
|
468
489
|
} catch { history = { available: true, count: 0, newestAt: null }; }
|
|
469
490
|
}
|
|
470
491
|
const tools = inspectTools(brainDir, PKG_ROOT);
|
|
492
|
+
// ── CHECKOUT (release-state visibility, 2026-08-14 incident) ──────────────
|
|
493
|
+
// Advisory, never a verdict layer: when the PROJECT itself is a versioned
|
|
494
|
+
// source checkout, report mechanically whether HEAD carries its own release
|
|
495
|
+
// tag — the 1.3.107 release shipped from an off-trunk branch because no
|
|
496
|
+
// surface made branch/release state visible at the moment of action, and
|
|
497
|
+
// doctor is the one client-neutral surface every agent can query. Only set
|
|
498
|
+
// when a git identity AND a package version exist (release state is
|
|
499
|
+
// meaningless without a version to be released); any probe failure omits
|
|
500
|
+
// the block. collectRepoState is bounded + 60s-cached, so this adds no
|
|
501
|
+
// meaningful cost to a doctor run.
|
|
502
|
+
let checkout = null;
|
|
503
|
+
if (repoStateLib && typeof repoStateLib.collectRepoState === 'function') {
|
|
504
|
+
try {
|
|
505
|
+
const state = repoStateLib.collectRepoState(projectDir);
|
|
506
|
+
if (state && state.packageVersion) {
|
|
507
|
+
checkout = {
|
|
508
|
+
branch: state.branch,
|
|
509
|
+
headShort: state.headShort,
|
|
510
|
+
version: state.packageVersion,
|
|
511
|
+
tagsAtHead: state.tagsAtHead,
|
|
512
|
+
releaseState: state.isReleaseTag ? 'released-tag' : 'untagged-working-tree',
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
} catch { checkout = null; }
|
|
516
|
+
}
|
|
471
517
|
const env = opts.env || process.env;
|
|
472
518
|
// MCP passes the adopted live id explicitly. The CLI can inherit a host id,
|
|
473
519
|
// but never invents one: a guessed sender would display somebody else's note.
|
|
@@ -553,7 +599,10 @@ export function inspect(opts = {}) {
|
|
|
553
599
|
const actions = [];
|
|
554
600
|
if (!version.installed) actions.push('npx klypix-mcp install # no brain installed on this machine');
|
|
555
601
|
else {
|
|
556
|
-
|
|
602
|
+
// The old remediation here named scripts/deploy-brain.mjs — a script that
|
|
603
|
+
// does not exist in the published package, so the one string every client
|
|
604
|
+
// renders verbatim was unrunnable. Point at commands that actually run.
|
|
605
|
+
if (version.dirty) actions.push('npx klypix-mcp install --force # running uncommitted (dirty) source code — restore the released npm version, or commit and re-deploy deliberately with --allow-untagged');
|
|
557
606
|
if (npm && npm.matches === false) actions.push(`npx klypix-mcp install # installed brain v${version.baked} < npm latest v${npm.latest}`);
|
|
558
607
|
if (running.matchesInstalled === false) actions.push(`/mcp reconnect (or restart the session) # LIVE server v${running.version} ≠ installed v${version.baked} — the running MCP server is stale`);
|
|
559
608
|
if (version.supervisorCapable && running.known && !supervisors.active) actions.push('/mcp reconnect once # activate the zero-restart supervisor; compatible future core updates hot-swap automatically');
|
|
@@ -576,7 +625,9 @@ export function inspect(opts = {}) {
|
|
|
576
625
|
}
|
|
577
626
|
}
|
|
578
627
|
|
|
579
|
-
|
|
628
|
+
// `checkout` is additive (schema-stable): downstream renderers keep parsing
|
|
629
|
+
// every existing field; it never feeds layers/verdict/actions by design.
|
|
630
|
+
return { verdict, layers, drifted, readinessWarnings, version, running, supervisors, autoUpdate, hooks, codexSmart, codexHooks, gitCapture, history, tools, peers, sessions: peers, receipts: peers.receipts, receiptSessionId, harness, npm, decayGuard, checkout, project: { dir: projectDir, brainPath, hasBrain }, brainDir, actions };
|
|
580
631
|
}
|
|
581
632
|
|
|
582
633
|
// One-line drift summary (empty when clean) — for a footer / status line.
|
|
@@ -723,6 +774,17 @@ export function render(r, opts = {}) {
|
|
|
723
774
|
: `${c.dim}${gc.state} — installs automatically at the next session start${c.rst}`;
|
|
724
775
|
L.push(`${gmark} ${c.bold}GIT-CAP${c.rst} ${detail}`);
|
|
725
776
|
}
|
|
777
|
+
// CHECKOUT — release state of the project's own source tree. Advisory only
|
|
778
|
+
// (never a verdict layer): its job is to make branch + released-tag truth
|
|
779
|
+
// visible at the moment of action, not to fail anything.
|
|
780
|
+
if (r.checkout) {
|
|
781
|
+
const where = `${r.checkout.branch ? `branch ${r.checkout.branch}` : 'detached HEAD'}, head ${r.checkout.headShort || '?'}`;
|
|
782
|
+
if (r.checkout.releaseState === 'released-tag') {
|
|
783
|
+
L.push(`${ok} ${c.bold}CHECKOUT${c.rst} source checkout at its release tag: v${r.checkout.version} (${where})`);
|
|
784
|
+
} else {
|
|
785
|
+
L.push(`${warn}${c.bold}CHECKOUT${c.rst} untagged working tree — v${r.checkout.version} has no release tag at HEAD (${c.yel}${where}${c.rst}) ${c.dim}· a build/release from here ships unreleased code (advisory)${c.rst}`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
726
788
|
// Restore points — say the count and the age, because "you can undo an
|
|
727
789
|
// accidental delete" is only believable if the machine can show the receipts.
|
|
728
790
|
if (r.history?.available) {
|