@compr/opscontext-mcp 2.7.0 → 2.8.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/CHANGELOG.md +42 -0
- package/README.md +3 -1
- package/dist/cli.js +7 -4
- package/dist/fleet-health.d.ts +57 -0
- package/dist/fleet-health.js +172 -0
- package/dist/index.js +40 -3
- package/dist/install-autostart.d.ts +1 -0
- package/dist/install-autostart.js +26 -4
- package/dist/install-claude-hook.js +14 -1
- package/dist/server-registry.d.ts +13 -1
- package/dist/server-registry.js +43 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,48 @@ All notable changes to OpsContext for AI Agents (previously ContextEngine — MC
|
|
|
4
4
|
|
|
5
5
|
> Entries for 2.2.0 through 2.4.0 were not backfilled here; see `docs/sessions/SESSION_19` through `SESSION_21` for those releases.
|
|
6
6
|
|
|
7
|
+
## [2.8.0] 2026-09-06: health is measured, never estimated
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Fleet health** (`[HEALTH-IS-MEASURED-NEVER-ESTIMATED]`, `src/fleet-health.ts`): the indexing
|
|
12
|
+
server writes `~/.contextengine/fleet-health.json` once a minute: servers on an old build
|
|
13
|
+
(version drift), shared-index writes in the last hour (reindex rate, ceiling 30), today's
|
|
14
|
+
pre-commit blocks with file and reason, learnings-store refusals, learnings saved (creates,
|
|
15
|
+
not a sweep's updates), and the last release for which `verify-release` passed. Every number
|
|
16
|
+
is counted from the audit log, the server registry or a file on disk. Warnings list measured
|
|
17
|
+
problems only. Shown by `contextengine servers` (exit 1 on any warning) and by `end-session`
|
|
18
|
+
§ 3b; each server logs "N server(s) on an old build" when that number changes.
|
|
19
|
+
- `index.write` audit events carry the writer's pid.
|
|
20
|
+
|
|
21
|
+
### VS Code extension 0.12.0
|
|
22
|
+
|
|
23
|
+
- **Status bar redesign** (Session 25 item 4): warning colour only on a measured problem (a
|
|
24
|
+
store refusal, servers on an old build, an index-write storm, commits made since the last
|
|
25
|
+
saved CE session, no MCP session, too many uncommitted files). Never on a timer. Default text
|
|
26
|
+
`CE` plus blocks prevented and recalls surfaced. Tooltip: measured problems, today's counts,
|
|
27
|
+
the last blocks with file and reason, servers and roles, version and last verified release,
|
|
28
|
+
the repo's CE session and commits since it was saved. "Time saved" is gone from the bar and
|
|
29
|
+
the info panel: it was a multiplier nobody could check.
|
|
30
|
+
- `fleetHealthPoller.ts` reads the health file every 15 s and treats a file older than 5 min as
|
|
31
|
+
absent.
|
|
32
|
+
|
|
33
|
+
## [2.7.1] 2026-09-06: the autostart agent is the standing indexer; servers --cost
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- **`install-autostart` writes a plist that makes the agent the standing indexer**
|
|
38
|
+
(`[AUTOSTART-IS-THE-STANDING-INDEXER]`): `ProcessType Standard` instead of `Background` (as
|
|
39
|
+
Background it got 2.5 s of CPU in 15 minutes under load and served nobody),
|
|
40
|
+
`CONTEXTENGINE_SHARED_INDEX=1`, and `CONTEXTENGINE_CONFIG` / `CONTEXTENGINE_WORKSPACES` passed
|
|
41
|
+
through from the installing shell so its corpus id equals the chats'. The Claude-memory skip is
|
|
42
|
+
no longer a default; it is passed through only when the installer itself ran with it. Re-run
|
|
43
|
+
`install-autostart --force` to pick this up.
|
|
44
|
+
- **`contextengine servers --cost`**: CPU time and resident memory per server and in total, the
|
|
45
|
+
number that was invisible on 2026-09-05 until someone ran `ps` by hand.
|
|
46
|
+
- **The session-gate wrapper prefers a global install** of the CLI (`npm root -g`) over the
|
|
47
|
+
directory the installer ran from, so an npx cache prune cannot break the Stop hook.
|
|
48
|
+
|
|
7
49
|
## [2.7.0] 2026-09-06: the session gate ships in the package
|
|
8
50
|
|
|
9
51
|
### Added
|
package/README.md
CHANGED
|
@@ -162,9 +162,11 @@ Companion commands: `uninstall-autostart`, `autostart-status`.
|
|
|
162
162
|
**3c. Wire Claude Code terminal sessions**
|
|
163
163
|
|
|
164
164
|
```bash
|
|
165
|
-
|
|
165
|
+
npm i -g @compr/opscontext-mcp && opscontext install-claude-hook
|
|
166
166
|
```
|
|
167
167
|
|
|
168
|
+
(Prefer the global install here: the hook scripts keep absolute paths to the CLI, and an `npx` cache copy can be pruned.)
|
|
169
|
+
|
|
168
170
|
Adds `UserPromptSubmit`, `PostToolUse`, and `SessionStart` hook entries to `~/.claude/settings.json` so every Claude Code prompt + tool call lands in the same audit log as the browser events, plus a `Stop` entry: the **session gate** (2.7.0). A Claude Code turn cannot end while the repo's OpsContext session is older than the last commit; the agent is told which session to save, which session doc to update, and how far the agent docs are behind. No more "did you save the session?" at the end of a day. Details: `npx @compr/opscontext-mcp session-gate --help`.
|
|
169
171
|
|
|
170
172
|
Verify:
|
package/dist/cli.js
CHANGED
|
@@ -656,6 +656,7 @@ import { buildCostReport } from "./cost-report.js";
|
|
|
656
656
|
import { getStagedFiles, runSecretScan, runDocCoverage, runCommitMessageRequired, runRuleParity, formatSecretViolations, formatDocCoverageViolations, formatSecretViolationsJson, formatDocCoverageViolationsJson, formatCommitMessageViolations, formatCommitMessageViolationsJson, formatRuleParityViolations, formatRuleParityViolationsJson, } from "./hooks.js";
|
|
657
657
|
import { safeAppend } from "./audit.js";
|
|
658
658
|
import { listServers, formatServers } from "./server-registry.js";
|
|
659
|
+
import { computeFleetHealth, formatFleetHealth } from "./fleet-health.js";
|
|
659
660
|
import { installSkill, locateBundledSkill, buildManagedBlock, syncClaudeMd, } from "./claude-integration.js";
|
|
660
661
|
import { fileURLToPath } from "url";
|
|
661
662
|
// ---------------------------------------------------------------------------
|
|
@@ -2233,7 +2234,7 @@ async function cliEndSession() {
|
|
|
2233
2234
|
// --- Check 3b: running servers ([LOCK] [SERVERS-ARE-INVENTORIED]) ---
|
|
2234
2235
|
checks.push("## 3b. Running servers\n");
|
|
2235
2236
|
const fleet = listServers();
|
|
2236
|
-
checks.push("```\n" + formatServers(fleet) + "\n```");
|
|
2237
|
+
checks.push("```\n" + formatServers(fleet) + "\n" + formatFleetHealth(computeFleetHealth({ version: readPackageVersion(), report: fleet })) + "\n```");
|
|
2237
2238
|
if (fleet.warnings.length > 0)
|
|
2238
2239
|
failCount += fleet.warnings.length;
|
|
2239
2240
|
checks.push("");
|
|
@@ -2527,7 +2528,7 @@ Usage:
|
|
|
2527
2528
|
Export hash-chained audit log (evidence aligned with
|
|
2528
2529
|
SOC 2 CC7.2 + ISO 27001 A.12.4.1 — not a certification)
|
|
2529
2530
|
contextengine audit-verify Verify audit log chain integrity (tamper detection)
|
|
2530
|
-
contextengine servers
|
|
2531
|
+
contextengine servers [--cost] List running MCP servers, their build vs the file on disk, role; --cost adds CPU time and memory
|
|
2531
2532
|
contextengine audit-redact-ack Acknowledge deliberately redacted records on the chain (--index i,j --reason "...")
|
|
2532
2533
|
contextengine audit-rotate [--keep-days N] [--max-records N] [--dry-run]
|
|
2533
2534
|
Move old history into an archive segment. Archives
|
|
@@ -2742,8 +2743,10 @@ else if (command === "audit-rotate") {
|
|
|
2742
2743
|
}
|
|
2743
2744
|
else if (command === "servers") {
|
|
2744
2745
|
const fleet = listServers();
|
|
2745
|
-
|
|
2746
|
-
|
|
2746
|
+
const health = computeFleetHealth({ version: readPackageVersion(), report: fleet });
|
|
2747
|
+
console.log(formatServers(fleet, undefined, { cost: process.argv.includes("--cost") }));
|
|
2748
|
+
console.log(formatFleetHealth(health));
|
|
2749
|
+
process.exit(fleet.warnings.length > 0 || health.warnings.length > 0 ? 1 : 0);
|
|
2747
2750
|
}
|
|
2748
2751
|
else if (command === "audit-verify") {
|
|
2749
2752
|
cliAuditVerify().catch((err) => {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type ServerReport } from "./server-registry.js";
|
|
2
|
+
export interface FleetHealth {
|
|
3
|
+
generatedAt: string;
|
|
4
|
+
version: string;
|
|
5
|
+
writerPid: number;
|
|
6
|
+
servers: {
|
|
7
|
+
total: number;
|
|
8
|
+
indexers: number;
|
|
9
|
+
readers: number;
|
|
10
|
+
/** Servers whose loaded build differs from the file on disk. */
|
|
11
|
+
stale: Array<{
|
|
12
|
+
pid: number;
|
|
13
|
+
version: string;
|
|
14
|
+
build: string;
|
|
15
|
+
cwd: string;
|
|
16
|
+
}>;
|
|
17
|
+
diskBuild: string | null;
|
|
18
|
+
};
|
|
19
|
+
reindex: {
|
|
20
|
+
/** Shared-index writes in the last hour, all corpora. */
|
|
21
|
+
lastHourWrites: number;
|
|
22
|
+
perCorpus: Record<string, number>;
|
|
23
|
+
/** Above this many writes per hour a warning is raised. */
|
|
24
|
+
threshold: number;
|
|
25
|
+
};
|
|
26
|
+
today: {
|
|
27
|
+
/** Pre-commit blocks (hook.block) since local midnight. */
|
|
28
|
+
blocks: number;
|
|
29
|
+
/** Store refusals (unreadable, shrink refused, growth refused) since local midnight. */
|
|
30
|
+
refusals: number;
|
|
31
|
+
learningsSaved: number;
|
|
32
|
+
/** Newest last: time, kind, one-line detail. */
|
|
33
|
+
lastBlocks: Array<{
|
|
34
|
+
ts: string;
|
|
35
|
+
kind: string;
|
|
36
|
+
detail: string;
|
|
37
|
+
}>;
|
|
38
|
+
};
|
|
39
|
+
/** The newest release for which verify-release passed on this machine, or null. */
|
|
40
|
+
lastVerifiedRelease: string | null;
|
|
41
|
+
/** Measured problems only. Empty means green. */
|
|
42
|
+
warnings: string[];
|
|
43
|
+
}
|
|
44
|
+
export declare const REINDEX_PER_HOUR_WARN = 30;
|
|
45
|
+
export declare function fleetHealthPath(): string;
|
|
46
|
+
/** The highest version with a verify-release marker; version order, not file time (ties). */
|
|
47
|
+
export declare function lastVerifiedRelease(dir?: string): string | null;
|
|
48
|
+
export declare function computeFleetHealth(opts?: {
|
|
49
|
+
now?: Date;
|
|
50
|
+
version?: string;
|
|
51
|
+
auditPath?: string;
|
|
52
|
+
report?: ServerReport;
|
|
53
|
+
}): FleetHealth;
|
|
54
|
+
/** Temp file + rename: a reader never sees half a file. */
|
|
55
|
+
export declare function writeFleetHealth(h: FleetHealth): string;
|
|
56
|
+
export declare function formatFleetHealth(h: FleetHealth): string;
|
|
57
|
+
//# sourceMappingURL=fleet-health.d.ts.map
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// [LOCKED] [HEALTH-IS-MEASURED-NEVER-ESTIMATED] 2026-09-06
|
|
2
|
+
// [NEVER] put a number in this file that comes from a timer, a multiplier or a guess; every
|
|
3
|
+
// field is counted from the audit log, the server registry, git, or a file on disk.
|
|
4
|
+
// WHY: the VS Code status bar showed "CE SAVE SESSION" in yellow on a wall-clock timer and
|
|
5
|
+
// "~N min saved" from a multiplier nobody could check (Session 25 item 4). Meanwhile the
|
|
6
|
+
// real problems of 2026-09-05 (eleven servers re-embedding, load average 230; two servers
|
|
7
|
+
// on a stale build re-importing 1,766 records; a store growth of 1,766 in one minute) had no
|
|
8
|
+
// surface at all. A nag without evidence trains the user to ignore the bar; a fact does not.
|
|
9
|
+
// FIX: one function computes the fleet's health from evidence, one writer (the indexer) drops
|
|
10
|
+
// it into ~/.contextengine/fleet-health.json every minute, and every surface (servers CLI,
|
|
11
|
+
// end-session, the status bar) reads the same file. Warnings list measured problems only.
|
|
12
|
+
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, readdirSync, renameSync, writeFileSync } from "fs";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { homedir } from "os";
|
|
15
|
+
import { listServers } from "./server-registry.js";
|
|
16
|
+
export const REINDEX_PER_HOUR_WARN = 30;
|
|
17
|
+
const TAIL_BYTES = 8 * 1024 * 1024;
|
|
18
|
+
function ceHome() {
|
|
19
|
+
return process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine");
|
|
20
|
+
}
|
|
21
|
+
export function fleetHealthPath() {
|
|
22
|
+
return join(ceHome(), "fleet-health.json");
|
|
23
|
+
}
|
|
24
|
+
/** The last `bytes` of a file as complete lines (the first partial line is dropped). */
|
|
25
|
+
function tailLines(path, bytes) {
|
|
26
|
+
if (!existsSync(path))
|
|
27
|
+
return [];
|
|
28
|
+
let fd = null;
|
|
29
|
+
try {
|
|
30
|
+
fd = openSync(path, "r");
|
|
31
|
+
const size = fstatSync(fd).size;
|
|
32
|
+
const start = Math.max(0, size - bytes);
|
|
33
|
+
const buf = Buffer.alloc(size - start);
|
|
34
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
35
|
+
let text = buf.toString("utf8");
|
|
36
|
+
if (start > 0) {
|
|
37
|
+
const nl = text.indexOf("\n");
|
|
38
|
+
text = nl === -1 ? "" : text.slice(nl + 1);
|
|
39
|
+
}
|
|
40
|
+
return text.split("\n").filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
if (fd !== null)
|
|
47
|
+
closeSync(fd);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function parseRecords(lines) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const l of lines) {
|
|
53
|
+
try {
|
|
54
|
+
const r = JSON.parse(l);
|
|
55
|
+
if (r && typeof r.ts === "string" && typeof r.event === "string")
|
|
56
|
+
out.push(r);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* a torn line, ignored */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
function localMidnight(now) {
|
|
65
|
+
const d = new Date(now);
|
|
66
|
+
d.setHours(0, 0, 0, 0);
|
|
67
|
+
return d;
|
|
68
|
+
}
|
|
69
|
+
function blockDetail(p = {}) {
|
|
70
|
+
const check = String(p.check ?? "block");
|
|
71
|
+
if (p.file)
|
|
72
|
+
return `${check}: ${String(p.file)}${p.line ? `:${String(p.line)}` : ""}${p.pattern_id ? ` (${String(p.pattern_id)})` : ""}`;
|
|
73
|
+
if (p.requires_section)
|
|
74
|
+
return `${check}: ${String(p.requires_section)}`;
|
|
75
|
+
if (p.reason)
|
|
76
|
+
return `${check}: ${String(p.reason)}`;
|
|
77
|
+
return check;
|
|
78
|
+
}
|
|
79
|
+
/** The highest version with a verify-release marker; version order, not file time (ties). */
|
|
80
|
+
export function lastVerifiedRelease(dir = ceHome()) {
|
|
81
|
+
let files = [];
|
|
82
|
+
try {
|
|
83
|
+
files = readdirSync(dir);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const versions = files.map((f) => /^verified-(\d+\.\d+\.\d+)$/.exec(f)?.[1]).filter((v) => !!v);
|
|
89
|
+
if (versions.length === 0)
|
|
90
|
+
return null;
|
|
91
|
+
const key = (v) => v.split(".").map(Number);
|
|
92
|
+
versions.sort((a, b) => { const x = key(a), y = key(b); return (x[0] - y[0]) || (x[1] - y[1]) || (x[2] - y[2]); });
|
|
93
|
+
return versions[versions.length - 1];
|
|
94
|
+
}
|
|
95
|
+
export function computeFleetHealth(opts = {}) {
|
|
96
|
+
const now = opts.now ?? new Date();
|
|
97
|
+
const report = opts.report ?? listServers();
|
|
98
|
+
const audit = opts.auditPath ?? join(ceHome(), "audit.log");
|
|
99
|
+
const records = parseRecords(tailLines(audit, TAIL_BYTES));
|
|
100
|
+
const midnight = localMidnight(now).getTime();
|
|
101
|
+
const hourAgo = now.getTime() - 3_600_000;
|
|
102
|
+
const perCorpus = {};
|
|
103
|
+
let lastHourWrites = 0, blocks = 0, refusals = 0, learningsSaved = 0;
|
|
104
|
+
const lastBlocks = [];
|
|
105
|
+
for (const r of records) {
|
|
106
|
+
const t = Date.parse(r.ts);
|
|
107
|
+
if (Number.isNaN(t))
|
|
108
|
+
continue;
|
|
109
|
+
if (r.event === "index.write" && t >= hourAgo) {
|
|
110
|
+
lastHourWrites++;
|
|
111
|
+
const c = String(r.payload?.corpus ?? "?");
|
|
112
|
+
perCorpus[c] = (perCorpus[c] ?? 0) + 1;
|
|
113
|
+
}
|
|
114
|
+
if (t < midnight)
|
|
115
|
+
continue;
|
|
116
|
+
if (r.event === "hook.block") {
|
|
117
|
+
blocks++;
|
|
118
|
+
lastBlocks.push({ ts: r.ts, kind: "pre-commit", detail: blockDetail(r.payload) });
|
|
119
|
+
}
|
|
120
|
+
else if (r.event === "learning.store_unreadable" || r.event === "learning.store_shrink_refused" || r.event === "learning.store_growth_refused") {
|
|
121
|
+
refusals++;
|
|
122
|
+
lastBlocks.push({ ts: r.ts, kind: "store", detail: r.event.replace("learning.store_", "").replace(/_/g, " ") });
|
|
123
|
+
}
|
|
124
|
+
else if (r.event === "learning.save" && r.payload?.mode !== "update")
|
|
125
|
+
learningsSaved++; // a sweep's updates are not saves
|
|
126
|
+
}
|
|
127
|
+
const stale = report.servers.filter((s) => s.staleBuild).map((s) => ({ pid: s.pid, version: s.version, build: s.build, cwd: s.cwd }));
|
|
128
|
+
const diskBuild = report.servers.find((s) => s.currentBuild)?.currentBuild ?? null;
|
|
129
|
+
const indexers = report.servers.filter((s) => s.role !== "reader").length;
|
|
130
|
+
const warnings = [];
|
|
131
|
+
if (stale.length > 0)
|
|
132
|
+
warnings.push(`${stale.length} server(s) on an old build (pid ${stale.map((s) => s.pid).join(", ")}): reload their windows`);
|
|
133
|
+
if (lastHourWrites > REINDEX_PER_HOUR_WARN)
|
|
134
|
+
warnings.push(`${lastHourWrites} shared-index writes in the last hour (ceiling ${REINDEX_PER_HOUR_WARN}): something saves in a loop`);
|
|
135
|
+
if (refusals > 0)
|
|
136
|
+
warnings.push(`${refusals} learnings-store refusal(s) today: a write looked like a wipe or a runaway import`);
|
|
137
|
+
for (const w of report.warnings)
|
|
138
|
+
if (/index on their own/.test(w))
|
|
139
|
+
warnings.push(w);
|
|
140
|
+
return {
|
|
141
|
+
generatedAt: now.toISOString(),
|
|
142
|
+
version: opts.version ?? "unknown",
|
|
143
|
+
writerPid: process.pid,
|
|
144
|
+
servers: { total: report.servers.length, indexers, readers: report.servers.length - indexers, stale, diskBuild },
|
|
145
|
+
reindex: { lastHourWrites, perCorpus, threshold: REINDEX_PER_HOUR_WARN },
|
|
146
|
+
today: { blocks, refusals, learningsSaved, lastBlocks: lastBlocks.slice(-3) },
|
|
147
|
+
lastVerifiedRelease: lastVerifiedRelease(),
|
|
148
|
+
warnings,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** Temp file + rename: a reader never sees half a file. */
|
|
152
|
+
export function writeFleetHealth(h) {
|
|
153
|
+
const path = fleetHealthPath();
|
|
154
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
155
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
156
|
+
writeFileSync(tmp, JSON.stringify(h, null, 2) + "\n");
|
|
157
|
+
renameSync(tmp, path);
|
|
158
|
+
return path;
|
|
159
|
+
}
|
|
160
|
+
export function formatFleetHealth(h) {
|
|
161
|
+
const lines = [];
|
|
162
|
+
lines.push(`health: ${h.warnings.length === 0 ? "green" : `${h.warnings.length} measured problem(s)`} (v${h.version}, last verified release ${h.lastVerifiedRelease ?? "none"}, ${h.generatedAt.slice(11, 19)}Z)`);
|
|
163
|
+
lines.push(` servers ${h.servers.total}: ${h.servers.indexers} indexing, ${h.servers.readers} reading, ${h.servers.stale.length} on an old build`);
|
|
164
|
+
lines.push(` shared-index writes last hour: ${h.reindex.lastHourWrites} (ceiling ${h.reindex.threshold})`);
|
|
165
|
+
lines.push(` today: ${h.today.blocks} block(s) prevented, ${h.today.refusals} store refusal(s), ${h.today.learningsSaved} learning(s) saved`);
|
|
166
|
+
for (const b of h.today.lastBlocks)
|
|
167
|
+
lines.push(` ${b.ts.slice(11, 19)}Z ${b.kind}: ${b.detail}`);
|
|
168
|
+
for (const w of h.warnings)
|
|
169
|
+
lines.push(` ⚠ ${w}`);
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=fleet-health.js.map
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { listProjects, checkPorts, runComplianceAudit, formatProjectList, format
|
|
|
13
13
|
import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
|
|
14
14
|
import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog, safeAppend } from "./audit.js";
|
|
15
15
|
import { registerServer, listServers, formatServers } from "./server-registry.js";
|
|
16
|
+
import { computeFleetHealth, writeFleetHealth } from "./fleet-health.js";
|
|
16
17
|
import { startEventIngestServer } from "./http-server.js";
|
|
17
18
|
import { detect } from "./detector.js";
|
|
18
19
|
import { buildCostReport } from "./cost-report.js";
|
|
@@ -210,7 +211,7 @@ function publishIndex() {
|
|
|
210
211
|
keys: chunks.map(embedKeyOf),
|
|
211
212
|
});
|
|
212
213
|
lastIndexMtime = sharedIndexMtime(corpus);
|
|
213
|
-
safeAppend("index.write", { corpus, seq: indexSeq, chunks: chunks.length, vectors: embeddedChunks.length, bytes: r.bytes, ms: r.ms });
|
|
214
|
+
safeAppend("index.write", { pid: process.pid, corpus, seq: indexSeq, chunks: chunks.length, vectors: embeddedChunks.length, bytes: r.bytes, ms: r.ms });
|
|
214
215
|
console.error(`[ContextEngine] 📤 Shared index written: seq ${indexSeq}, ${chunks.length} chunks, ${Math.round(r.bytes / 1024)} KB, ${r.ms} ms`);
|
|
215
216
|
}
|
|
216
217
|
catch (err) {
|
|
@@ -422,11 +423,39 @@ function evaluateRole(reason) {
|
|
|
422
423
|
startIndexPolling();
|
|
423
424
|
}
|
|
424
425
|
}
|
|
426
|
+
let healthTick = 0;
|
|
427
|
+
let lastStaleCount = -1;
|
|
428
|
+
/**
|
|
429
|
+
* The indexer writes ~/.contextengine/fleet-health.json once a minute: version drift, reindex
|
|
430
|
+
* rate, today's blocks and refusals, the last verified release. Every surface reads that file.
|
|
431
|
+
* [LOCK] [HEALTH-IS-MEASURED-NEVER-ESTIMATED]
|
|
432
|
+
*/
|
|
433
|
+
function publishHealth() {
|
|
434
|
+
try {
|
|
435
|
+
const h = computeFleetHealth({ version: PKG_VERSION });
|
|
436
|
+
if (h.servers.stale.length !== lastStaleCount) {
|
|
437
|
+
lastStaleCount = h.servers.stale.length;
|
|
438
|
+
if (lastStaleCount > 0)
|
|
439
|
+
console.error(`[ContextEngine] 🧭 ${lastStaleCount} server(s) on an old build: pid ${h.servers.stale.map((s) => s.pid).join(", ")}`);
|
|
440
|
+
}
|
|
441
|
+
if (role === "indexer")
|
|
442
|
+
writeFleetHealth(h);
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
console.error(`[ContextEngine] ⚠ fleet health failed: ${err.message}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
425
448
|
function startRolePolling() {
|
|
426
|
-
if (rolePoll
|
|
449
|
+
if (rolePoll)
|
|
427
450
|
return;
|
|
428
|
-
rolePoll = setInterval(() =>
|
|
451
|
+
rolePoll = setInterval(() => {
|
|
452
|
+
if (corpus)
|
|
453
|
+
evaluateRole("periodic");
|
|
454
|
+
if (++healthTick % 4 === 0)
|
|
455
|
+
publishHealth(); // every 60 s
|
|
456
|
+
}, ROLE_POLL_MS);
|
|
429
457
|
rolePoll.unref();
|
|
458
|
+
setTimeout(publishHealth, 5_000).unref();
|
|
430
459
|
}
|
|
431
460
|
// ---------------------------------------------------------------------------
|
|
432
461
|
// MCP Server
|
|
@@ -1402,6 +1431,14 @@ async function main() {
|
|
|
1402
1431
|
else
|
|
1403
1432
|
startIndexPolling();
|
|
1404
1433
|
startRolePolling();
|
|
1434
|
+
// 5b. A daemon (launchd, OPSCONTEXT_DAEMON=1) has stdin on /dev/null, so the stdio transport
|
|
1435
|
+
// closes at once; as a reader it holds no watchers and every poller is unref'd, and the loop
|
|
1436
|
+
// would drain and exit 0, which KeepAlive turns into a restart every 10 s. Hold the loop open.
|
|
1437
|
+
// [LOCK] [AUTOSTART-IS-THE-STANDING-INDEXER]
|
|
1438
|
+
if (process.env.OPSCONTEXT_DAEMON === "1") {
|
|
1439
|
+
setInterval(() => { }, 60_000);
|
|
1440
|
+
console.error("[ContextEngine] 🛡 Daemon mode: staying alive without an MCP client");
|
|
1441
|
+
}
|
|
1405
1442
|
// 6. Boot the local HTTP event-ingest endpoint for the browser extension.
|
|
1406
1443
|
// Local 127.0.0.1:7842 only; auth via shared secret at
|
|
1407
1444
|
// ~/.contextengine/extension-secret (see init-extension-secret CLI).
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export declare function buildPlist(nodePath: string, entryPath: string, nodeBinDir: string, env?: NodeJS.ProcessEnv): string;
|
|
1
2
|
export declare function cliInstallAutostart(args: string[]): Promise<void>;
|
|
2
3
|
export declare function cliUninstallAutostart(args: string[]): Promise<void>;
|
|
3
4
|
export declare function cliAutostartStatus(args: string[]): Promise<void>;
|
|
@@ -87,7 +87,30 @@ function detectOpscontextEntry() {
|
|
|
87
87
|
}
|
|
88
88
|
return null;
|
|
89
89
|
}
|
|
90
|
-
|
|
90
|
+
// [LOCKED] [AUTOSTART-IS-THE-STANDING-INDEXER] 2026-09-06
|
|
91
|
+
// [NEVER] run the agent as ProcessType Background, drop CONTEXTENGINE_SHARED_INDEX from its
|
|
92
|
+
// environment, or give it a corpus the chats do not have.
|
|
93
|
+
// WHY: measured 2026-09-05 (SESSION_26): as Background the agent got 2.5 s of CPU in 15 minutes
|
|
94
|
+
// under load, took 4.5 min from exec to main(), served no MCP client (stdin is /dev/null),
|
|
95
|
+
// lost the browser-event port to whichever chat started first, and, with the memory skip and
|
|
96
|
+
// no config, indexed a corpus no chat used. It burned CPU keeping a cache warm that never
|
|
97
|
+
// hit. As the standing indexer it is the oldest server at every login, so it wins the
|
|
98
|
+
// election, every chat opens onto a ready index, and it owns the port from boot.
|
|
99
|
+
// FIX: Standard priority; the shared-index flag; CONTEXTENGINE_CONFIG passed through from the
|
|
100
|
+
// installing shell so its corpus id equals the chats'; the memory skip only if the installer
|
|
101
|
+
// was itself run with it (an explicit choice, not a default).
|
|
102
|
+
export function buildPlist(nodePath, entryPath, nodeBinDir, env = process.env) {
|
|
103
|
+
// OPSCONTEXT_DAEMON tells the server it has no MCP client on stdin and must stay alive on its
|
|
104
|
+
// own: as a reader it holds no file watchers, and every poller is unref'd, so without this the
|
|
105
|
+
// event loop drained and launchd restarted it every 10 s (found 2026-09-06, first real run).
|
|
106
|
+
const passthrough = [["CONTEXTENGINE_SHARED_INDEX", "1"], ["OPSCONTEXT_DAEMON", "1"]];
|
|
107
|
+
if (env.CONTEXTENGINE_CONFIG)
|
|
108
|
+
passthrough.push(["CONTEXTENGINE_CONFIG", env.CONTEXTENGINE_CONFIG]);
|
|
109
|
+
if (env.CONTEXTENGINE_WORKSPACES)
|
|
110
|
+
passthrough.push(["CONTEXTENGINE_WORKSPACES", env.CONTEXTENGINE_WORKSPACES]);
|
|
111
|
+
if (env.OPSCONTEXT_SKIP_CLAUDE_MEMORY === "1")
|
|
112
|
+
passthrough.push(["OPSCONTEXT_SKIP_CLAUDE_MEMORY", "1"]);
|
|
113
|
+
const extraEnv = passthrough.map(([k, v]) => ` <key>${k}</key>\n <string>${v.replace(/&/g, "&").replace(/</g, "<")}</string>`).join("\n");
|
|
91
114
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
92
115
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
93
116
|
<plist version="1.0">
|
|
@@ -107,8 +130,7 @@ function buildPlist(nodePath, entryPath, nodeBinDir) {
|
|
|
107
130
|
<string>${nodeBinDir}:/usr/local/bin:/usr/bin:/bin</string>
|
|
108
131
|
<key>HOME</key>
|
|
109
132
|
<string>${homedir()}</string>
|
|
110
|
-
|
|
111
|
-
<string>1</string>
|
|
133
|
+
${extraEnv}
|
|
112
134
|
</dict>
|
|
113
135
|
|
|
114
136
|
<key>WorkingDirectory</key>
|
|
@@ -130,7 +152,7 @@ function buildPlist(nodePath, entryPath, nodeBinDir) {
|
|
|
130
152
|
<string>${join(LOG_DIR, "mcp-stderr.log")}</string>
|
|
131
153
|
|
|
132
154
|
<key>ProcessType</key>
|
|
133
|
-
<string>
|
|
155
|
+
<string>Standard</string>
|
|
134
156
|
</dict>
|
|
135
157
|
</plist>
|
|
136
158
|
`;
|
|
@@ -18,6 +18,7 @@ import { existsSync, readFileSync, writeFileSync, copyFileSync, chmodSync, mkdir
|
|
|
18
18
|
import { join, dirname } from "path";
|
|
19
19
|
import { homedir } from "os";
|
|
20
20
|
import { fileURLToPath } from "url";
|
|
21
|
+
import { execSync } from "child_process";
|
|
21
22
|
// [LOCK] [M2-ESM-FILENAME-FIX]: the package is "type": "module", so a bare __dirname is a
|
|
22
23
|
// ReferenceError at runtime. Found 2026-09-06 while adding the Stop gate: the defaults/ lookup
|
|
23
24
|
// below used `__dirname_esm`, and a real run against a throwaway HOME died with
|
|
@@ -70,6 +71,17 @@ function bundledHookSource() {
|
|
|
70
71
|
}
|
|
71
72
|
return null;
|
|
72
73
|
}
|
|
74
|
+
/** dist/cli.js of a global install, when there is one (same preference as install-autostart). */
|
|
75
|
+
function globalCliPath() {
|
|
76
|
+
try {
|
|
77
|
+
const root = execSync("npm root -g 2>/dev/null", { encoding: "utf-8" }).trim();
|
|
78
|
+
const c = join(root, "@compr", "opscontext-mcp", "dist", "cli.js");
|
|
79
|
+
return existsSync(c) ? c : null;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
73
85
|
export async function cliInstallClaudeHook(args) {
|
|
74
86
|
const help = args.includes("-h") || args.includes("--help");
|
|
75
87
|
if (help) {
|
|
@@ -143,7 +155,8 @@ Run: opscontext install-autostart
|
|
|
143
155
|
added++;
|
|
144
156
|
}
|
|
145
157
|
// Step 3: the Stop gate. Absolute node + CLI paths: hooks run without the user's shell PATH.
|
|
146
|
-
|
|
158
|
+
// Prefer the global install: an npx cache copy can be pruned and the hook would then exit 127.
|
|
159
|
+
const cliPath = globalCliPath() ?? join(__dirname_esm, "cli.js");
|
|
147
160
|
writeFileSync(GATE_SCRIPT, `#!/bin/sh\n# Generated by \`opscontext install-claude-hook\`: the CE session gate on Claude Code Stop.\n# Exit 2 = the turn may not end yet (reason on stderr). See: contextengine session-gate --help\nexec "${process.execPath}" "${cliPath}" session-gate\n`);
|
|
148
161
|
chmodSync(GATE_SCRIPT, 0o755);
|
|
149
162
|
settings.hooks.Stop ??= [];
|
|
@@ -43,5 +43,17 @@ export declare function registerServer(opts: {
|
|
|
43
43
|
};
|
|
44
44
|
/** Read every record, drop the dead ones, compare builds with the files on disk now. */
|
|
45
45
|
export declare function listServers(): ServerReport;
|
|
46
|
-
|
|
46
|
+
/** CPU seconds consumed and resident memory of a live pid, from ps (hardcoded argv, no shell). */
|
|
47
|
+
export declare function processCost(pid: number): {
|
|
48
|
+
cpuSeconds: number;
|
|
49
|
+
rssMb: number;
|
|
50
|
+
} | null;
|
|
51
|
+
/**
|
|
52
|
+
* The listing. With `cost`, each line also carries CPU time and memory, and the total: this is
|
|
53
|
+
* the number that was invisible on 2026-09-05 (9.3 CPU-hours across eleven servers) until
|
|
54
|
+
* someone ran ps by hand. [LOCK] [ONE-INDEXER-MANY-READERS]
|
|
55
|
+
*/
|
|
56
|
+
export declare function formatServers(report: ServerReport, home?: string, opts?: {
|
|
57
|
+
cost?: boolean;
|
|
58
|
+
}): string;
|
|
47
59
|
//# sourceMappingURL=server-registry.d.ts.map
|
package/dist/server-registry.js
CHANGED
|
@@ -145,16 +145,57 @@ export function listServers() {
|
|
|
145
145
|
}
|
|
146
146
|
return report;
|
|
147
147
|
}
|
|
148
|
-
|
|
148
|
+
/** CPU seconds consumed and resident memory of a live pid, from ps (hardcoded argv, no shell). */
|
|
149
|
+
export function processCost(pid) {
|
|
150
|
+
try {
|
|
151
|
+
const out = execFileSync("ps", ["-o", "time=,rss=", "-p", String(pid)], { encoding: "utf8", timeout: 2000 }).trim();
|
|
152
|
+
const [time, rss] = out.split(/\s+/);
|
|
153
|
+
if (!time)
|
|
154
|
+
return null;
|
|
155
|
+
const parts = time.split(":").map(Number);
|
|
156
|
+
const cpuSeconds = parts.length === 3 ? parts[0] * 3600 + parts[1] * 60 + parts[2] : parts[0] * 60 + (parts[1] || 0);
|
|
157
|
+
return { cpuSeconds, rssMb: Math.round(Number(rss || 0) / 1024) };
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function fmtCpu(s) {
|
|
164
|
+
if (s >= 3600)
|
|
165
|
+
return `${(s / 3600).toFixed(1)} CPU-h`;
|
|
166
|
+
if (s >= 60)
|
|
167
|
+
return `${Math.round(s / 60)} CPU-min`;
|
|
168
|
+
return `${Math.round(s)} CPU-s`;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The listing. With `cost`, each line also carries CPU time and memory, and the total: this is
|
|
172
|
+
* the number that was invisible on 2026-09-05 (9.3 CPU-hours across eleven servers) until
|
|
173
|
+
* someone ran ps by hand. [LOCK] [ONE-INDEXER-MANY-READERS]
|
|
174
|
+
*/
|
|
175
|
+
export function formatServers(report, home = homedir(), opts = {}) {
|
|
149
176
|
const short = (p) => p.startsWith(home) ? "~" + p.slice(home.length) : p;
|
|
150
177
|
const lines = [];
|
|
151
178
|
lines.push(`${report.servers.length} server(s) running${report.removed ? `, ${report.removed} dead record(s) removed` : ""}`);
|
|
179
|
+
let cpuTotal = 0, rssTotal = 0;
|
|
152
180
|
for (const s of report.servers) {
|
|
153
181
|
const t = s.started.slice(11, 19) + "Z";
|
|
154
182
|
const flag = s.staleBuild ? `STALE BUILD (disk ${s.currentBuild})` : s.currentBuild === null ? "script missing on disk" : "current";
|
|
155
183
|
const role = s.role ? ` ${s.role.padEnd(7)} corpus ${s.corpus ?? "?"}` : "";
|
|
156
|
-
|
|
184
|
+
let cost = "";
|
|
185
|
+
if (opts.cost) {
|
|
186
|
+
const c = processCost(s.pid);
|
|
187
|
+
if (c) {
|
|
188
|
+
cpuTotal += c.cpuSeconds;
|
|
189
|
+
rssTotal += c.rssMb;
|
|
190
|
+
cost = ` ${fmtCpu(c.cpuSeconds).padStart(11)} ${String(c.rssMb).padStart(5)} MB`;
|
|
191
|
+
}
|
|
192
|
+
else
|
|
193
|
+
cost = " (cost unknown)";
|
|
194
|
+
}
|
|
195
|
+
lines.push(` pid ${String(s.pid).padEnd(6)} ${t} v${s.version} build ${s.build} ${flag}${role}${cost} parent ${s.parent} cwd ${short(s.cwd)}`);
|
|
157
196
|
}
|
|
197
|
+
if (opts.cost && report.servers.length > 0)
|
|
198
|
+
lines.push(` total: ${fmtCpu(cpuTotal)}, ${rssTotal} MB resident, across ${report.servers.length} server(s)`);
|
|
158
199
|
for (const w of report.warnings)
|
|
159
200
|
lines.push(` ⚠ ${w}`);
|
|
160
201
|
return lines.join("\n");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compr/opscontext-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"description": "OpsContext for AI Agents — read-only fleet visibility (PM2/nginx/Docker/git/cron) + tamper-evident audit log + policy-as-code hooks. The ops + compliance layer Claude Code can't grow natively.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|