klypix-mcp 1.41.0 → 1.43.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-conformance.mjs +9 -0
- package/bin/klypix-worker.mjs +19 -1
- package/package.json +2 -2
- package/src/agent-presence.mjs +84 -17
- package/src/agent-rules.mjs +22 -4
- package/src/brain-doctor.mjs +73 -12
- package/src/codex-brain-hook.mjs +20 -0
- package/src/global-brain-hook.mjs +363 -50
- package/src/klypix-core.mjs +28 -6
- package/src/klypix-format.mjs +534 -32
- package/src/mcp-presence.mjs +113 -17
- package/src/mcp-supervisor.mjs +104 -2
|
@@ -57,6 +57,15 @@ fs.writeFileSync(path.join(vault, 'brain.klypix'), await buildKlypixMap({
|
|
|
57
57
|
const baseEnv = Object.fromEntries(
|
|
58
58
|
Object.entries(process.env).filter(([, value]) => typeof value === 'string'),
|
|
59
59
|
);
|
|
60
|
+
// The harness simulates two INDEPENDENT hosts. When it itself runs inside an
|
|
61
|
+
// agent session (Claude Code exports CLAUDE_PID + CLAUDE_CODE_SESSION_ID to
|
|
62
|
+
// children), both simulated clients would inherit the SAME host identity and
|
|
63
|
+
// be correctly treated as one session's twin halves — failing every two-client
|
|
64
|
+
// check. Strip host/session identity; each client gets its own KLYPIX_SESSION_ID.
|
|
65
|
+
for (const key of [
|
|
66
|
+
'CLAUDE_PID', 'KLYPIX_HOST_PID', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_SESSION_ID',
|
|
67
|
+
'CODEX_THREAD_ID', 'CURSOR_SESSION_ID', 'CLINE_SESSION_ID', 'WINDSURF_SESSION_ID',
|
|
68
|
+
]) delete baseEnv[key];
|
|
60
69
|
|
|
61
70
|
async function connect(name, sessionId) {
|
|
62
71
|
const logs = [];
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -329,6 +329,24 @@ server.registerTool('brain_sync', {
|
|
|
329
329
|
}, async ({ project, intent, files, phase, include_context }) => {
|
|
330
330
|
const totalStartedAt = Date.now();
|
|
331
331
|
const report = mcpPresence.sync({ project, intent, files, phase });
|
|
332
|
+
// Class-C ship observation, host-neutral: brain_sync is the ONE surface every
|
|
333
|
+
// MCP host calls at task start, so an MCP-only project (no Claude/Codex hook)
|
|
334
|
+
// still notices a release nobody narrated. Queued here, drained at the next
|
|
335
|
+
// brain write. Zero network, ~2 cheap git/fs reads, never throws.
|
|
336
|
+
let shipNotice = '';
|
|
337
|
+
if (phase === 'start') {
|
|
338
|
+
try {
|
|
339
|
+
const dir = project ? path.resolve(project) : mcpPresence.vault;
|
|
340
|
+
if (typeof brainFormat.observeShipDrift === 'function' && dir) {
|
|
341
|
+
const { execFileSync } = await import('child_process');
|
|
342
|
+
shipNotice = brainFormat.observeShipDrift(dir, {
|
|
343
|
+
gitRun: (args) => execFileSync('git', String(args).split(/\s+/).filter(Boolean), {
|
|
344
|
+
cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000,
|
|
345
|
+
}),
|
|
346
|
+
}).notice || '';
|
|
347
|
+
}
|
|
348
|
+
} catch { /* observation is best-effort — never fail a sync */ }
|
|
349
|
+
}
|
|
332
350
|
let taskContext = null;
|
|
333
351
|
if (phase !== 'complete' && include_context !== false && (intent || files?.length)) {
|
|
334
352
|
taskContext = await opBrainTaskContext({
|
|
@@ -368,7 +386,7 @@ server.registerTool('brain_sync', {
|
|
|
368
386
|
return {
|
|
369
387
|
content: [{
|
|
370
388
|
type: 'text',
|
|
371
|
-
text: [report.text, contextText, timingText].filter(Boolean).join('\n\n'),
|
|
389
|
+
text: [report.text, shipNotice, contextText, timingText].filter(Boolean).join('\n\n'),
|
|
372
390
|
}],
|
|
373
391
|
structuredContent,
|
|
374
392
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.43.0",
|
|
4
4
|
"description": "Every project gets a brain — one open .klypix file your AI agents read, write, and argue from, over MCP. Works with Claude, Codex, Cursor, Cline, any model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"node": ">=18"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
61
|
-
"test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/codex-hooks.mjs && node test/agent-presence.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/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/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/decay-status.mjs && node test/decay-hook.mjs"
|
|
61
|
+
"test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/codex-hooks.mjs && node test/agent-presence.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/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/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/presence-visibility.mjs"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
64
|
"@modelcontextprotocol/ext-apps": "^1.7.4",
|
package/src/agent-presence.mjs
CHANGED
|
@@ -113,6 +113,29 @@ function pruneMessages(messages, now) {
|
|
|
113
113
|
.filter((message) => message?.id && now - Number(message.ts || 0) < MESSAGE_FRESH_MS);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// Cap the message lane WITHOUT silently destroying undelivered notes: the old
|
|
117
|
+
// flat `.slice(-30)` evicted the OLDEST rows first regardless of delivery, so a
|
|
118
|
+
// burst of >30 messages could destroy a note nobody had seen yet. Evict
|
|
119
|
+
// delivered (seen-by-someone) messages first, oldest first; only then the
|
|
120
|
+
// oldest undelivered ones. Order of survivors is preserved.
|
|
121
|
+
export function capMessages(messages, cap = 30) {
|
|
122
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
123
|
+
if (list.length <= cap) return list;
|
|
124
|
+
let excess = list.length - cap;
|
|
125
|
+
const dropped = new Set();
|
|
126
|
+
const evict = (predicate) => {
|
|
127
|
+
for (const message of list) {
|
|
128
|
+
if (!excess) return;
|
|
129
|
+
if (dropped.has(message) || !predicate(message)) continue;
|
|
130
|
+
dropped.add(message);
|
|
131
|
+
excess--;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
evict((m) => Array.isArray(m?.seen) && m.seen.length > 0); // delivered at least once
|
|
135
|
+
evict(() => true); // still over cap → oldest of the rest
|
|
136
|
+
return list.filter((message) => !dropped.has(message));
|
|
137
|
+
}
|
|
138
|
+
|
|
116
139
|
function normalizeFiles(files) {
|
|
117
140
|
const seen = new Set();
|
|
118
141
|
const out = [];
|
|
@@ -140,11 +163,13 @@ export function upsertSession({
|
|
|
140
163
|
permissionMode = null,
|
|
141
164
|
branch = null,
|
|
142
165
|
intent,
|
|
166
|
+
intentSource = null,
|
|
143
167
|
files,
|
|
144
168
|
replaceFiles = false,
|
|
145
169
|
event = null,
|
|
146
170
|
channel = null,
|
|
147
171
|
cwd = null,
|
|
172
|
+
hostPid = null,
|
|
148
173
|
home,
|
|
149
174
|
now = Date.now(),
|
|
150
175
|
}) {
|
|
@@ -162,6 +187,14 @@ export function upsertSession({
|
|
|
162
187
|
const mergedFiles = files === undefined
|
|
163
188
|
? normalizeFiles(previous.files)
|
|
164
189
|
: normalizeFiles(replaceFiles ? files : [...(previous.files || []), ...(files || [])]);
|
|
190
|
+
// Intent freshness is its OWN timestamp: lastSeen is refreshed by heartbeats
|
|
191
|
+
// that never touch intent, so without intentAt a 100-minute-old intent renders
|
|
192
|
+
// under "active just now" (2026-07-29 audit). Stamped only when the intent
|
|
193
|
+
// VALUE actually changes; additive — old rows/readers are unaffected.
|
|
194
|
+
const nextIntent = intent !== undefined
|
|
195
|
+
? String(intent || '').replace(/\s+/g, ' ').trim().slice(0, 160)
|
|
196
|
+
: (previous.intent || '');
|
|
197
|
+
const intentChanged = intent !== undefined && nextIntent !== (previous.intent || '');
|
|
165
198
|
const next = {
|
|
166
199
|
...previous,
|
|
167
200
|
id: String(id),
|
|
@@ -172,13 +205,19 @@ export function upsertSession({
|
|
|
172
205
|
model: model ?? previous.model ?? null,
|
|
173
206
|
permissionMode: permissionMode ?? previous.permissionMode ?? null,
|
|
174
207
|
branch: branch ?? previous.branch ?? null,
|
|
175
|
-
intent:
|
|
208
|
+
intent: nextIntent,
|
|
209
|
+
...(intentChanged ? { intentAt: now, intentSource: intentSource || 'declared' }
|
|
210
|
+
: (previous.intentAt ? { intentAt: previous.intentAt, intentSource: previous.intentSource || null } : {})),
|
|
176
211
|
files: mergedFiles,
|
|
177
212
|
event: event ?? previous.event ?? null,
|
|
178
213
|
...(Object.keys(channelSeen).length
|
|
179
214
|
? { channels: Object.keys(channelSeen), channelSeen }
|
|
180
215
|
: {}),
|
|
181
216
|
cwd: cwd ? path.resolve(cwd) : (previous.cwd || path.dirname(brainPath)),
|
|
217
|
+
// Host-process correlation (e.g. Claude Code exports CLAUDE_PID to every
|
|
218
|
+
// child): lets readers recognize one logical session's lifecycle row and
|
|
219
|
+
// MCP row as TWINS instead of two independent peers.
|
|
220
|
+
hostPid: Number(hostPid) || previous.hostPid || null,
|
|
182
221
|
startedAt: previous.startedAt || now,
|
|
183
222
|
lastSeen: now,
|
|
184
223
|
};
|
|
@@ -188,7 +227,7 @@ export function upsertSession({
|
|
|
188
227
|
fs.writeFileSync(laneFile, JSON.stringify({
|
|
189
228
|
...data,
|
|
190
229
|
sessions: kept.slice(-40),
|
|
191
|
-
messages: pruneMessages(data.messages, now)
|
|
230
|
+
messages: capMessages(pruneMessages(data.messages, now), 30),
|
|
192
231
|
}));
|
|
193
232
|
return kept.sort((a, b) => Number(b.lastSeen || 0) - Number(a.lastSeen || 0));
|
|
194
233
|
} finally {
|
|
@@ -226,7 +265,7 @@ export function removeSession({ brainPath, id, channel = null, home, now = Date.
|
|
|
226
265
|
fs.writeFileSync(laneFile, JSON.stringify({
|
|
227
266
|
...data,
|
|
228
267
|
sessions,
|
|
229
|
-
messages: pruneMessages(data.messages, now)
|
|
268
|
+
messages: capMessages(pruneMessages(data.messages, now), 30),
|
|
230
269
|
}));
|
|
231
270
|
return sessions;
|
|
232
271
|
} finally {
|
|
@@ -271,24 +310,45 @@ export function receiveMessages({
|
|
|
271
310
|
&& messageTargetsSession(message, me, sessionId));
|
|
272
311
|
if (!unseen.length) return [];
|
|
273
312
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
313
|
+
// Deliver-then-ack, never ack-then-truncate: the old path acked EVERY unseen
|
|
314
|
+
// message under lock but returned only the first 6 — anything past the cap
|
|
315
|
+
// was marked seen without ever being shown, permanently lost under the
|
|
316
|
+
// delivered-once contract (2026-07-29 audit). Ack exactly: the delivered 6,
|
|
317
|
+
// plus self-ignored texts and text-duplicates OF a delivered message (their
|
|
318
|
+
// content was shown once via the twin). Overflow stays unacked and arrives
|
|
319
|
+
// on the next call.
|
|
320
|
+
const normText = (message) => String(message.text || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
282
321
|
const ignored = new Set(ignoreTexts.map((text) => String(text || '').replace(/\s+/g, ' ').trim().toLowerCase()));
|
|
283
322
|
const shown = [];
|
|
323
|
+
const dropped = []; // ignored (own sends) — content intentionally never shown
|
|
284
324
|
const seenText = new Set();
|
|
325
|
+
const dupesByText = new Map();
|
|
285
326
|
for (const message of unseen) {
|
|
286
|
-
const key =
|
|
287
|
-
if (!key || ignored.has(key)
|
|
327
|
+
const key = normText(message);
|
|
328
|
+
if (!key || ignored.has(key)) { dropped.push(message); continue; }
|
|
329
|
+
if (seenText.has(key)) {
|
|
330
|
+
if (!dupesByText.has(key)) dupesByText.set(key, []);
|
|
331
|
+
dupesByText.get(key).push(message);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
288
334
|
seenText.add(key);
|
|
289
335
|
shown.push(message);
|
|
290
336
|
}
|
|
291
|
-
|
|
337
|
+
const delivered = shown.slice(0, 6);
|
|
338
|
+
const ackIds = new Set(delivered.map((message) => message.id));
|
|
339
|
+
for (const message of dropped) ackIds.add(message.id);
|
|
340
|
+
for (const message of delivered) {
|
|
341
|
+
for (const dupe of dupesByText.get(normText(message)) || []) ackIds.add(dupe.id);
|
|
342
|
+
}
|
|
343
|
+
if (ackIds.size) {
|
|
344
|
+
for (const message of messages) {
|
|
345
|
+
if (!ackIds.has(message.id)) continue;
|
|
346
|
+
if (!Array.isArray(message.seen)) message.seen = [];
|
|
347
|
+
if (!message.seen.includes(sessionId)) message.seen.push(sessionId);
|
|
348
|
+
}
|
|
349
|
+
fs.writeFileSync(laneFile, JSON.stringify({ ...data, sessions, messages }));
|
|
350
|
+
}
|
|
351
|
+
return delivered;
|
|
292
352
|
} finally {
|
|
293
353
|
if (gotLock) releaseLock(lockFile);
|
|
294
354
|
}
|
|
@@ -368,7 +428,7 @@ export function postPresenceMessage({
|
|
|
368
428
|
fs.writeFileSync(laneFile, JSON.stringify({
|
|
369
429
|
...data,
|
|
370
430
|
sessions,
|
|
371
|
-
messages: messages
|
|
431
|
+
messages: capMessages(messages, 30),
|
|
372
432
|
}));
|
|
373
433
|
return { posted: true, message };
|
|
374
434
|
} finally {
|
|
@@ -405,15 +465,22 @@ export function formatPresenceMessage(sessions, selfId, { includeSolo = false, n
|
|
|
405
465
|
lines.push('Other active sessions:');
|
|
406
466
|
for (const session of others.slice(0, 8)) {
|
|
407
467
|
const ageMin = Math.max(0, Math.round((now - Number(session.lastSeen || now)) / 60_000));
|
|
468
|
+
// A heartbeat refreshes lastSeen while carrying an old intent forward — show
|
|
469
|
+
// the INTENT's own age when it meaningfully lags the heartbeat, so a
|
|
470
|
+
// 100-minute-old task line can never read as "what they're doing right now".
|
|
471
|
+
const intentAgeMin = session.intentAt ? Math.max(0, Math.round((now - Number(session.intentAt)) / 60_000)) : null;
|
|
472
|
+
const intentAge = intentAgeMin !== null && intentAgeMin - ageMin > 3 ? ` (intent set ${intentAgeMin}m ago)` : '';
|
|
408
473
|
const details = [
|
|
409
474
|
clientLabel(session),
|
|
410
475
|
session.branch ? `branch ${session.branch}` : null,
|
|
411
|
-
session.intent ? `"${String(session.intent).slice(0, 90)}"` : null,
|
|
476
|
+
session.intent ? `"${String(session.intent).slice(0, 90)}"${intentAge}` : null,
|
|
412
477
|
`${ageMin}m ago`,
|
|
413
478
|
].filter(Boolean);
|
|
414
479
|
lines.push(`- ${String(session.id).slice(0, 8)}: ${details.join(' | ')}`);
|
|
415
480
|
}
|
|
416
|
-
|
|
481
|
+
// v1.32.0 law: a truncated list must never render as a complete one.
|
|
482
|
+
if (others.length > 8) lines.push(`- …and ${others.length - 8} more live session(s) not listed — brain_doctor shows all.`);
|
|
483
|
+
lines.push(`Presence is point-in-time (as of ${new Date(now).toISOString().slice(11, 16)}Z) — re-check before coordinating; use brain_message for a targeted note.`);
|
|
417
484
|
return lines.join('\n');
|
|
418
485
|
}
|
|
419
486
|
|
package/src/agent-rules.mjs
CHANGED
|
@@ -521,8 +521,15 @@ function classifyFenced(file, version, frontmatter = null) {
|
|
|
521
521
|
const raw = fs.readFileSync(file, 'utf8');
|
|
522
522
|
const fence = parseFence(raw);
|
|
523
523
|
if (!fence) return { status: 'missing' };
|
|
524
|
-
|
|
525
|
-
|
|
524
|
+
// Normalize CRLF before hashing (matching norm() in the frontmatter branch
|
|
525
|
+
// below): the stamped hash is always computed from the LF-only canonical
|
|
526
|
+
// instructions, so a git-autocrlf checkout or CRLF-saving editor flipped
|
|
527
|
+
// EVERY projected file to hand-edited at once — the destructive-repair class
|
|
528
|
+
// — when the true status was a losslessly healable 'stale' (2026-07-29 audit,
|
|
529
|
+
// the likely cause of "9/14 HAND-EDITED at the same stamp").
|
|
530
|
+
const bodyHash = sha8(fence.body.replace(/\r\n/g, '\n').trim());
|
|
531
|
+
if (fence.hash && bodyHash !== fence.hash) return { status: 'hand-edited', stampedVersion: fence.version };
|
|
532
|
+
const bodyCurrent = bodyHash === INSTRUCTIONS_HASH;
|
|
526
533
|
const stampConsistent = !fence.hash || fence.hash === INSTRUCTIONS_HASH;
|
|
527
534
|
if (bodyCurrent && stampConsistent) {
|
|
528
535
|
// Owned dedicated files are OURS wholesale — frontmatter included. Compare the
|
|
@@ -541,11 +548,20 @@ function classifyFenced(file, version, frontmatter = null) {
|
|
|
541
548
|
|
|
542
549
|
// Shared markdown (AGENTS.md, copilot-instructions.md, GEMINI.md): merge our fenced
|
|
543
550
|
// block in place, preserving the user's own prose outside the fence.
|
|
551
|
+
// A hand-edited file is about to be rebuilt wholesale — keep the human's version.
|
|
552
|
+
// One rolling .klypix-bak per file (repair stops being an all-or-nothing choice).
|
|
553
|
+
function backupHandEdited(file, status) {
|
|
554
|
+
if (status !== 'hand-edited' || !exists(file)) return;
|
|
555
|
+
try { fs.copyFileSync(file, file + '.klypix-bak'); } catch { /* best-effort */ }
|
|
556
|
+
}
|
|
557
|
+
|
|
544
558
|
function fenceMerge(file, version) {
|
|
545
559
|
// Zero-touch IFF the audit says 'ok' (same classifier, so check and write can
|
|
546
560
|
// never disagree): current+consistent block → byte-identical file, no stamp-only
|
|
547
561
|
// churn; a hand-edited stamp or stale body falls through and gets rebuilt.
|
|
548
|
-
|
|
562
|
+
const status = classifyFenced(file, version).status;
|
|
563
|
+
if (status === 'ok') return { action: 'unchanged' };
|
|
564
|
+
backupHandEdited(file, status);
|
|
549
565
|
let cur = '';
|
|
550
566
|
let had = false;
|
|
551
567
|
if (exists(file)) { cur = fs.readFileSync(file, 'utf8'); had = true; }
|
|
@@ -564,7 +580,9 @@ function fenceMerge(file, version) {
|
|
|
564
580
|
function writeDedicated(file, frontmatter, version) {
|
|
565
581
|
// Zero-touch IFF the audit says 'ok' — which for owned dedicated files includes
|
|
566
582
|
// the frontmatter (a stripped alwaysApply/trigger MUST be repaired, not skipped).
|
|
567
|
-
|
|
583
|
+
const status = classifyFenced(file, version, frontmatter || '').status;
|
|
584
|
+
if (status === 'ok') return { action: 'unchanged' };
|
|
585
|
+
backupHandEdited(file, status);
|
|
568
586
|
const had = exists(file);
|
|
569
587
|
const body = (frontmatter ? frontmatter + '\n' : '') + fencedBlock(version) + '\n';
|
|
570
588
|
ensureDir(file); fs.writeFileSync(file, body, 'utf8');
|
package/src/brain-doctor.mjs
CHANGED
|
@@ -183,6 +183,11 @@ function inspectRunning(brainDir, baked, now, self) {
|
|
|
183
183
|
}
|
|
184
184
|
|
|
185
185
|
// ── PEERS (alignment) layer ──────────────────────────────────────────────────
|
|
186
|
+
// A supervisor with a live pid can still be a ZOMBIE: recovery-failed means it
|
|
187
|
+
// has NO worker and every queued host request hangs — the pid-alive check alone
|
|
188
|
+
// rendered exactly that state as "✅ SUPERVISOR N live" (2026-07-29 audit,
|
|
189
|
+
// field pid 12312 exit 0xC0000142). These statuses mean the transport is
|
|
190
|
+
// impaired regardless of process liveness.
|
|
186
191
|
function inspectSupervisors(brainDir, baked) {
|
|
187
192
|
const dir = path.join(brainDir, '.supervisors');
|
|
188
193
|
let names = [];
|
|
@@ -191,9 +196,15 @@ function inspectSupervisors(brainDir, baked) {
|
|
|
191
196
|
for (const name of names) {
|
|
192
197
|
const state = readJson(path.join(dir, name), null);
|
|
193
198
|
if (!state?.pid || !isAlivePid(state.pid)) continue;
|
|
199
|
+
const status = state.status || 'unknown';
|
|
194
200
|
live.push({
|
|
195
201
|
pid: state.pid,
|
|
196
|
-
status
|
|
202
|
+
status,
|
|
203
|
+
// IMPAIRED = no live worker outside the normal boot window: tool calls
|
|
204
|
+
// hang/fail. 'restart-required' with a live active worker is DEGRADED
|
|
205
|
+
// (an update was rejected, the old worker keeps serving) — flagging that
|
|
206
|
+
// red as "tool calls hang" would be factually wrong (review-caught).
|
|
207
|
+
impaired: !state.active && status !== 'starting' && status !== 'awaiting-initialize',
|
|
197
208
|
activePid: state.active?.pid || null,
|
|
198
209
|
activeVersion: state.active?.version || null,
|
|
199
210
|
hotReloads: Number(state.hotReloads || 0),
|
|
@@ -205,29 +216,60 @@ function inspectSupervisors(brainDir, baked) {
|
|
|
205
216
|
active: live.length > 0,
|
|
206
217
|
count: live.length,
|
|
207
218
|
live,
|
|
219
|
+
impaired: live.filter(state => state.impaired),
|
|
208
220
|
matchesInstalled: live.length && baked
|
|
209
221
|
? live.every(state => state.activeVersion && cmpSemver(state.activeVersion, baked) === 0)
|
|
210
222
|
: null,
|
|
211
223
|
};
|
|
212
224
|
}
|
|
213
225
|
|
|
226
|
+
// Channel-aware liveness, mirroring agent-presence.mjs: an mcp-channel heartbeat
|
|
227
|
+
// is dead after 3 minutes even though the flat 10-minute window keeps the row —
|
|
228
|
+
// without this the hook, brain_sync, and doctor reported three different counts.
|
|
229
|
+
const MCP_SESSION_FRESH_MS = 3 * 60 * 1000;
|
|
230
|
+
const sessionLiveness = (s, now) => {
|
|
231
|
+
const channelSeen = (s?.channelSeen && typeof s.channelSeen === 'object' && !Array.isArray(s.channelSeen)) ? s.channelSeen : null;
|
|
232
|
+
if (channelSeen && Object.keys(channelSeen).length) {
|
|
233
|
+
const fresh = Object.entries(channelSeen)
|
|
234
|
+
.filter(([ch, seen]) => ch && now - Number(seen || 0) < (ch === 'mcp' ? MCP_SESSION_FRESH_MS : SESSION_FRESH_MS));
|
|
235
|
+
if (!fresh.length) return null;
|
|
236
|
+
return { lastSeen: Math.max(...fresh.map(([, seen]) => Number(seen))), channels: fresh.map(([ch]) => ch) };
|
|
237
|
+
}
|
|
238
|
+
const lastSeen = Number(s?.lastSeen || 0);
|
|
239
|
+
return now - lastSeen < SESSION_FRESH_MS ? { lastSeen, channels: Array.isArray(s?.channels) ? s.channels : [] } : null;
|
|
240
|
+
};
|
|
241
|
+
|
|
214
242
|
function inspectPeers(brainDir, brainPath, now) {
|
|
215
243
|
const file = path.join(brainDir, 'sessions', `${sha(normBrainPath(brainPath))}.json`);
|
|
216
244
|
const data = readJson(file, null);
|
|
217
245
|
const sessions = Array.isArray(data?.sessions) ? data.sessions : [];
|
|
218
|
-
const
|
|
219
|
-
|
|
246
|
+
const rawRecent = sessions.filter(s => s && now - (s.lastSeen || 0) < SESSION_FRESH_MS).length;
|
|
247
|
+
const live = sessions
|
|
248
|
+
.map(s => ({ s, liveness: sessionLiveness(s, now) }))
|
|
249
|
+
.filter(({ liveness }) => liveness)
|
|
250
|
+
.map(({ s, liveness }) => ({
|
|
220
251
|
id: String(s.id || '').slice(0, 8),
|
|
221
|
-
|
|
252
|
+
// 'unknown' stays 'unknown' — the old 'claude-code' fallback silently
|
|
253
|
+
// relabeled client-less rows, masking the writer that forgot to stamp.
|
|
254
|
+
client: s.client || 'unknown',
|
|
222
255
|
surface: s.surface || null,
|
|
223
256
|
model: s.model || null,
|
|
224
257
|
branch: s.branch || null,
|
|
225
258
|
intent: s.intent || '',
|
|
259
|
+
intentAgeMin: s.intentAt ? Math.round((now - Number(s.intentAt)) / 60000) : null,
|
|
226
260
|
files: Array.isArray(s.files) ? s.files : [],
|
|
227
|
-
channels:
|
|
228
|
-
|
|
261
|
+
channels: liveness.channels,
|
|
262
|
+
hostPid: s.hostPid || null,
|
|
263
|
+
// Sync-rich vs sync-silent: has this session declared any task scope?
|
|
264
|
+
synced: Boolean(String(s.intent || '').trim() || (Array.isArray(s.files) && s.files.length)),
|
|
265
|
+
lastSeenMin: Math.round((now - liveness.lastSeen) / 60000),
|
|
229
266
|
}));
|
|
230
|
-
|
|
267
|
+
// Twin rows = channel merge NOT occurring: >1 live row sharing one hostPid is
|
|
268
|
+
// one logical session split across ids (each twin inflates every count).
|
|
269
|
+
const byHost = new Map();
|
|
270
|
+
for (const p of live) { if (p.hostPid) byHost.set(p.hostPid, [...(byHost.get(p.hostPid) || []), p.id]); }
|
|
271
|
+
const twinGroups = [...byHost.entries()].filter(([, ids]) => ids.length > 1).map(([hostPid, ids]) => ({ hostPid, ids }));
|
|
272
|
+
return { file, live, count: live.length, rawRecent, syncedCount: live.filter(p => p.synced).length, twinGroups };
|
|
231
273
|
}
|
|
232
274
|
|
|
233
275
|
// ── DECAY-GUARD layer (fast-decay status protection, 2026-07-28) ─────────────
|
|
@@ -319,7 +361,12 @@ export function inspect(opts = {}) {
|
|
|
319
361
|
// stale-server incident. Unknown (server not booted since the heartbeat shipped)
|
|
320
362
|
// is NOT drift; it's a reconnect prompt.
|
|
321
363
|
running: !running.known ? 'unknown' : (running.matchesInstalled === false ? 'drift' : 'ok'),
|
|
322
|
-
|
|
364
|
+
// A recovery-failed / worker-less supervisor is an OUTAGE (queued tool calls
|
|
365
|
+
// hang), not health — pid-alive alone must never render it ok. A stale
|
|
366
|
+
// active-worker version is the same class of drift RUNNING reports.
|
|
367
|
+
supervisor: supervisors.active
|
|
368
|
+
? (supervisors.impaired.length ? 'drift' : (supervisors.matchesInstalled === false ? 'drift' : 'ok'))
|
|
369
|
+
: (version.supervisorCapable ? 'pending-reconnect' : 'legacy'),
|
|
323
370
|
autoUpdate: !autoUpdate.enabled ? 'off' : (autoUpdate.result === 'failed' ? 'warning' : 'ok'),
|
|
324
371
|
hooks: !hooks.settingsPresent ? 'absent' : (hooks.missing.length ? 'drift' : 'ok'),
|
|
325
372
|
// Codex MCP presence is the automatic baseline. Hooks are an OPTIONAL
|
|
@@ -352,6 +399,8 @@ export function inspect(opts = {}) {
|
|
|
352
399
|
if (hooks.missing.length) actions.push(`npx klypix-mcp install # half-wired: hooks not active — ${hooks.missing.join(', ')}`);
|
|
353
400
|
if (hasBrain && !harness.ok) actions.push('npx klypix-mcp link # harness configs drifted — re-project managed blocks');
|
|
354
401
|
if (layers.decayGuard === 'drift') actions.push('npx klypix-mcp install # decay-aware status guard missing/stale — stale build/deploy claims can render as CURRENT state');
|
|
402
|
+
for (const s of supervisors.impaired || []) actions.push(`/mcp reconnect # supervisor pid ${s.pid} is ${s.status} with no live worker — its tool calls hang until reconnected`);
|
|
403
|
+
if (peers.twinGroups && peers.twinGroups.length) actions.push(`/mcp reconnect # ${peers.twinGroups.length} session(s) split across twin lane rows (channel merge not occurring) — reconnect adopts the merged id`);
|
|
355
404
|
}
|
|
356
405
|
|
|
357
406
|
return { verdict, layers, drifted, version, running, supervisors, autoUpdate, hooks, codexSmart, codexHooks, tools, peers, sessions: peers, harness, npm, decayGuard, project: { dir: projectDir, brainPath, hasBrain }, brainDir, actions };
|
|
@@ -365,6 +414,7 @@ export function driftLine(r) {
|
|
|
365
414
|
if (r.version.dirty) bits.push('dirty deploy');
|
|
366
415
|
if (r.npm && r.npm.matches === false) bits.push(`v${r.version.baked}<${r.npm.latest}`);
|
|
367
416
|
if (r.running && r.running.matchesInstalled === false) bits.push(`live server v${r.running.version}≠installed v${r.version.baked} (/mcp reconnect)`);
|
|
417
|
+
if (r.supervisors?.impaired?.length) bits.push(`${r.supervisors.impaired.length} supervisor(s) IMPAIRED — tool calls hang (/mcp reconnect)`);
|
|
368
418
|
if (r.hooks.missing.length) bits.push(`${r.hooks.missing.length} hook(s) unwired`);
|
|
369
419
|
if (r.project.hasBrain && !r.harness.ok) bits.push(`${r.harness.drift.length} harness file(s) drifted`);
|
|
370
420
|
if (r.layers?.decayGuard === 'drift') bits.push('decay-guard stale (fast-decay status claims unstamped)');
|
|
@@ -405,9 +455,13 @@ export function render(r, opts = {}) {
|
|
|
405
455
|
// SUPERVISOR (stable host connection + replaceable worker)
|
|
406
456
|
if (r.supervisors?.active) {
|
|
407
457
|
const totalReloads = r.supervisors.live.reduce((sum, state) => sum + state.hotReloads, 0);
|
|
408
|
-
|
|
458
|
+
const impaired = r.supervisors.impaired || [];
|
|
459
|
+
const smark = impaired.length ? warn : ok;
|
|
460
|
+
const healthy = r.supervisors.count - impaired.length;
|
|
461
|
+
L.push(`${smark} ${c.bold}SUPERVISOR${c.rst} ${healthy} healthy${impaired.length ? ` · ${c.red}${impaired.length} IMPAIRED (no live worker — its tool calls hang)${c.rst}` : ' · zero-restart core activation ready'}${totalReloads ? ` · ${totalReloads} hot-swap${totalReloads === 1 ? '' : 's'}` : ''}`);
|
|
409
462
|
for (const state of r.supervisors.live) {
|
|
410
|
-
if (state.
|
|
463
|
+
if (state.impaired) L.push(` ${c.red}· pid ${state.pid} ${state.status}${state.lastError ? `: ${state.lastError}` : ''} — /mcp reconnect${c.rst}`);
|
|
464
|
+
else if (state.lastError) L.push(` ${c.yel}· pid ${state.pid} ${state.status}: ${state.lastError}${c.rst}`);
|
|
411
465
|
}
|
|
412
466
|
} else if (r.version.supervisorCapable) {
|
|
413
467
|
L.push(`${warn} ${c.bold}SUPERVISOR${c.rst} installed but this is a legacy direct-worker session · reconnect once to activate`);
|
|
@@ -472,8 +526,15 @@ export function render(r, opts = {}) {
|
|
|
472
526
|
// SESSIONS: this is an all-session count. A recent-chat row is not a heartbeat.
|
|
473
527
|
if (!r.sessions.count) L.push(`${ok} ${c.bold}SESSIONS${c.rst} 0 active sessions ${c.dim}(saved/recent chats are history, not active)${c.rst}`);
|
|
474
528
|
else {
|
|
475
|
-
|
|
476
|
-
|
|
529
|
+
const hiddenIdle = Math.max(0, (r.sessions.rawRecent || r.sessions.count) - r.sessions.count);
|
|
530
|
+
L.push(`${r.sessions.count > 1 ? warn : ok} ${c.bold}SESSIONS${c.rst} ${r.sessions.count} active session${r.sessions.count === 1 ? '' : 's'} · ${r.sessions.syncedCount ?? r.sessions.count} with declared task scope ${c.dim}(all hosts; channel-aware liveness${hiddenIdle ? `; +${hiddenIdle} row(s) with only a dead mcp channel excluded` : ''})${c.rst}`);
|
|
531
|
+
for (const p of r.sessions.live) {
|
|
532
|
+
const intentAge = p.intentAgeMin !== null && p.intentAgeMin - p.lastSeenMin > 3 ? ` (set ${p.intentAgeMin}m ago)` : '';
|
|
533
|
+
L.push(` · ${p.client}:${p.id}${p.branch ? ' @' + p.branch : ''}${p.channels.length ? ` [${p.channels.join('+')}]` : ''}${p.intent ? ` “${p.intent.slice(0, 50)}”${intentAge}` : ''}${p.synced ? '' : ` ${c.yel}· sync-silent (no brain_sync intent/scope)${c.rst}`} ${c.dim}(${p.lastSeenMin}m ago)${c.rst}`);
|
|
534
|
+
}
|
|
535
|
+
for (const g of (r.sessions.twinGroups || [])) {
|
|
536
|
+
L.push(` ${c.yel}⚠ twin rows — channel merge NOT occurring: host pid ${g.hostPid} appears as ${g.ids.join(' + ')} (one logical session counted ${g.ids.length}×)${c.rst}`);
|
|
537
|
+
}
|
|
477
538
|
}
|
|
478
539
|
|
|
479
540
|
// HARNESS
|
package/src/codex-brain-hook.mjs
CHANGED
|
@@ -31,6 +31,21 @@ function readInput() {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Thin binding of the engine's host-neutral ship observer to a Codex project
|
|
35
|
+
// dir. Guarded on the export so an older bundled klypix-format degrades to no
|
|
36
|
+
// observation rather than killing the hook.
|
|
37
|
+
function observeShipDrift(projectDir) {
|
|
38
|
+
try {
|
|
39
|
+
if (!projectDir || typeof brainFormat.observeShipDrift !== 'function') return '';
|
|
40
|
+
const gitRun = (args) => execFileSync('git', String(args).split(/\s+/).filter(Boolean), {
|
|
41
|
+
cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000,
|
|
42
|
+
});
|
|
43
|
+
return brainFormat.observeShipDrift(projectDir, { gitRun }).notice || '';
|
|
44
|
+
} catch {
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
34
49
|
function gitBranch(cwd) {
|
|
35
50
|
try {
|
|
36
51
|
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
@@ -262,6 +277,11 @@ async function main() {
|
|
|
262
277
|
if (event === 'SessionStart') {
|
|
263
278
|
emitSystemMessage([
|
|
264
279
|
formatPresenceMessage(sessions, sessionId, { includeSolo: true }),
|
|
280
|
+
// Class-C ship observation — the engine's, not a Claude-hook-local copy.
|
|
281
|
+
// Codex-driven projects had NO out-of-session ship detection at all, which
|
|
282
|
+
// is exactly where the incident class bites hardest (2026-07-29 review).
|
|
283
|
+
// The queue drains at the next brain write from any host.
|
|
284
|
+
observeShipDrift(projectDir),
|
|
265
285
|
stampReceivedMessages(messages),
|
|
266
286
|
]);
|
|
267
287
|
return;
|