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.
@@ -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 = [];
@@ -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.41.0",
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",
@@ -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: intent !== undefined ? String(intent || '').replace(/\s+/g, ' ').trim().slice(0, 160) : (previous.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).slice(-30),
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).slice(-30),
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
- const unseenIds = new Set(unseen.map((message) => message.id));
275
- for (const message of messages) {
276
- if (!unseenIds.has(message.id)) continue;
277
- if (!Array.isArray(message.seen)) message.seen = [];
278
- if (!message.seen.includes(sessionId)) message.seen.push(sessionId);
279
- }
280
- fs.writeFileSync(laneFile, JSON.stringify({ ...data, sessions, messages }));
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 = String(message.text || '').replace(/\s+/g, ' ').trim().toLowerCase();
287
- if (!key || ignored.has(key) || seenText.has(key)) continue;
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
- return shown.slice(0, 6);
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.slice(-30),
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
- lines.push('Coordinate before touching shared files; use brain_message for a targeted note.');
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
 
@@ -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
- if (fence.hash && sha8(fence.body.trim()) !== fence.hash) return { status: 'hand-edited', stampedVersion: fence.version };
525
- const bodyCurrent = sha8(fence.body.trim()) === INSTRUCTIONS_HASH;
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
- if (classifyFenced(file, version).status === 'ok') return { action: 'unchanged' };
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
- if (classifyFenced(file, version, frontmatter || '').status === 'ok') return { action: 'unchanged' };
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');
@@ -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: state.status || 'unknown',
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 live = sessions.filter(s => s && now - (s.lastSeen || 0) < SESSION_FRESH_MS)
219
- .map(s => ({
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
- client: s.client || 'claude-code',
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: Array.isArray(s.channels) ? s.channels : [],
228
- lastSeenMin: Math.round((now - (s.lastSeen || 0)) / 60000),
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
- return { file, live, count: live.length };
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
- supervisor: supervisors.active ? 'ok' : (version.supervisorCapable ? 'pending-reconnect' : 'legacy'),
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
- L.push(`${ok} ${c.bold}SUPERVISOR${c.rst} ${r.supervisors.count} live · zero-restart core activation ready${totalReloads ? ` · ${totalReloads} hot-swap${totalReloads === 1 ? '' : 's'}` : ''}`);
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.lastError) L.push(` ${c.yel}· pid ${state.pid} ${state.status}: ${state.lastError}${c.rst}`);
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
- L.push(`${r.sessions.count > 1 ? warn : ok} ${c.bold}SESSIONS${c.rst} ${r.sessions.count} active session${r.sessions.count === 1 ? '' : 's'} ${c.dim}(all hosts; MCP/lifecycle; 10-minute crash TTL)${c.rst}`);
476
- for (const p of r.sessions.live) L.push(` · ${p.client}:${p.id}${p.branch ? ' @' + p.branch : ''}${p.channels.length ? ` [${p.channels.join('+')}]` : ''}${p.intent ? ` “${p.intent.slice(0, 50)}”` : ''} ${c.dim}(${p.lastSeenMin}m ago)${c.rst}`);
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
@@ -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;