klypix-mcp 1.33.0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,11 +16,12 @@ You've seen the setup: point an AI at a folder of notes, watch the graph fill up
16
16
  npx klypix-mcp install
17
17
  ```
18
18
 
19
- Claude Code gets live hooks for auto-brief + auto-capture. Codex gets a native
20
- `~/.codex/config.toml` MCP registration and conditional global guidance that activates
21
- only when a project contains `./brain.klypix`. Existing MCP servers, Codex settings, and
22
- personal instructions are preserved and backed up before KLYPIX-owned blocks change.
23
- Restart Codex after installation so it loads the new server.
19
+ Claude Code keeps its live hooks for auto-brief + auto-capture. Codex gets a native
20
+ `~/.codex/config.toml` MCP registration, conditional global guidance, and lifecycle
21
+ hooks for truthful cross-agent presence and one-time messages. Existing MCP servers,
22
+ hooks, settings, and personal instructions are preserved; KLYPIX owns only its fenced
23
+ blocks and `codex-brain-hook.mjs` handlers, with backups before changes. Restart Codex
24
+ after installation and approve the KLYPIX hooks when Codex presents its trust review.
24
25
 
25
26
  Give a project a brain by dropping a `brain.klypix` in it — the
26
27
  [KLYPIX app](https://klypix.com) does it in one click (*Save canvas as project brain*),
@@ -73,7 +74,7 @@ Any MCP client gets the tools without the hooks. For **Claude Desktop**, in `cla
73
74
 
74
75
  Then ask your agent things like *"what did we decide about auth?"*, *"challenge this: let's switch to polling"*, or *"turn these notes into a board."*
75
76
 
76
- ## The 16 verbs
77
+ ## The 17 verbs
77
78
 
78
79
  | Tool | What it does |
79
80
  |---|---|
@@ -83,7 +84,7 @@ Then ask your agent things like *"what did we decide about auth?"*, *"challenge
83
84
  | `brain_reconcile` | Find stale-vs-correction pairs + unrecorded migrations |
84
85
  | `brain_insights` | Hubs, orphaned decisions, stale questions, area sizes |
85
86
  | `brain_garden` | Maintenance pass over the brain |
86
- | `brain_doctor` | Self-diagnosis: version alignment, hooks wired, projection drift |
87
+ | `brain_doctor` | Self-diagnosis: version, host adapters, active lifecycle sessions, projection drift |
87
88
  | `brain_message` | Session-to-session coordination notes |
88
89
  | `brain_connect` | Find + draw related-but-unlinked cards |
89
90
  | `canvas_view` | The board as an MCP App — Apps-capable chats get an interactive spatial view; everyone else gets clean text |
@@ -96,7 +97,19 @@ Then ask your agent things like *"what did we decide about auth?"*, *"challenge
96
97
 
97
98
  ### Tools vs. the *automatic* brain
98
99
 
99
- This package is the **agent-neutral read/write surface** — any MCP client gets the tools above on demand (*pull*), in any project. `install` wires Claude Code's live hooks and Codex's native MCP + conditional global guidance; `link` projects the repository-level MCP and instruction files used by Codex and the other coding agents. Full transcript-driven auto-capture remains a Claude Code hook capability; Codex and other hookless clients capture durable decisions through the MCP instructions and `brain_note`.
100
+ This package is the **agent-neutral read/write surface** — any MCP client gets the tools above on demand (*pull*), in any project. `install` wires Claude Code's existing capture path plus Codex's native MCP, conditional guidance, and separate presence adapter; `link` projects the repository-level MCP and instruction files used by other coding agents. Full transcript-driven auto-capture remains a Claude Code capability. Codex reports lifecycle presence and captures durable decisions explicitly through `brain_note`; hookless clients can use the MCP surface but are not reported as active.
101
+
102
+ ### Agent-neutral live presence
103
+
104
+ An active session means a host lifecycle hook heartbeated within 10 minutes; a row in a
105
+ recent-chat list is history, not presence. Claude Code and Codex publish into the same
106
+ per-brain lane, while each host keeps its own adapter and capture behavior. `SessionEnd`
107
+ removes clean exits immediately, and the TTL covers crashes.
108
+
109
+ Future hosts can import `klypix-mcp/presence` and map their lifecycle events to
110
+ `upsertSession`, `removeSession`, and `receiveMessages`. The shared contract accepts
111
+ `id`, `client`, `surface`, `model`, `branch`, `intent`, and touched `files`; host-specific
112
+ transcript parsing stays outside the protocol.
100
113
 
101
114
  ### Updates — the propagation contract
102
115
 
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // klypix-doctor — `npx klypix-mcp doctor`. The brain's self-check: is THIS machine's
3
- // brain current, are the 4 Claude Code hooks wired, what verbs does it expose, who else
4
- // is live on this project's brain, and is the harness projection in sync? ONE fact, ONE
5
- // reconcile block. Read-only (never writes). Exits 0 = ALIGNED, 1 = DRIFTED — so it
3
+ // brain current, are the Claude capture and Codex presence adapters wired, what verbs
4
+ // does it expose, which lifecycle sessions are live, and is the harness projection in
5
+ // sync? ONE fact, ONE reconcile block. Read-only (never writes). Exits 0 = ALIGNED,
6
+ // 1 = DRIFTED — so it
6
7
  // doubles as a pre-commit / CI readiness gate.
7
8
  //
8
9
  // npx klypix-mcp doctor # this project + this machine's brain
@@ -21,6 +21,7 @@ import {
21
21
  mcpServerEntry,
22
22
  mergeCodexGlobalInstructions,
23
23
  } from '../src/agent-rules.mjs';
24
+ import { mergeCodexPresenceHooks } from '../src/codex-hooks.mjs';
24
25
 
25
26
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
27
  const PKG_ROOT = path.resolve(__dirname, '..');
@@ -45,25 +46,36 @@ function copyDir(src, dest) {
45
46
  }
46
47
  }
47
48
 
48
- // Codex has two independent integration layers: MCP gives it the brain tools,
49
- // while a conditional managed block in ~/.codex/AGENTS.md tells every session
50
- // to use those tools when ./brain.klypix exists.
49
+ // Codex has three independent integration layers: MCP gives it the brain tools,
50
+ // a conditional managed block in ~/.codex/AGENTS.md tells every brain project to
51
+ // use them, and lifecycle hooks publish live presence to the shared session lane.
51
52
  function wireCodex() {
52
53
  const mcp = connectCodexMcpServer({
53
54
  configPath: CODEX_CONFIG,
54
55
  entry: mcpServerEntry({ vault: '.', home: HOME }),
55
56
  });
56
57
  const instructions = mergeCodexGlobalInstructions(HOME);
57
- return { mcp, instructions, ok: mcp.ok && instructions.ok };
58
+ const hookScript = path.join(BRAIN_DIR, 'codex-brain-hook.mjs');
59
+ const presence = exists(hookScript)
60
+ ? mergeCodexPresenceHooks({
61
+ home: HOME,
62
+ command: `node "${fwd(hookScript)}"`,
63
+ })
64
+ : { ok: true, action: 'not-available' };
65
+ return { mcp, instructions, presence, ok: mcp.ok && instructions.ok && presence.ok };
58
66
  }
59
67
 
60
68
  function reportCodex(result) {
61
69
  if (result.ok) {
62
- console.log(`✓ wired Codex: MCP tools (${result.mcp.action}) + conditional project-brain guidance (${result.instructions.action})`);
70
+ const live = result.presence.action === 'not-available'
71
+ ? 'live presence pending a current brain bundle'
72
+ : `live presence (${result.presence.action})`;
73
+ console.log(`✓ wired Codex: MCP tools (${result.mcp.action}) + conditional project-brain guidance (${result.instructions.action}) + ${live}`);
63
74
  return;
64
75
  }
65
76
  if (!result.mcp.ok) console.error(`⚠ Codex MCP was not changed: ${result.mcp.error}`);
66
77
  if (!result.instructions.ok) console.error(`⚠ Codex guidance was not changed: ${result.instructions.error}`);
78
+ if (!result.presence.ok) console.error(`⚠ Codex live presence was not changed: ${result.presence.error}`);
67
79
  console.error(' Claude Code installation is intact. Fix the Codex warning, then re-run this command.');
68
80
  }
69
81
 
@@ -199,7 +211,7 @@ try {
199
211
  // canvas-view-app.html is the canvas_view MCP App UI — staged raw (an HTML
200
212
  // file must never get a JS-comment banner) beside the flat server, which
201
213
  // resolves it via its ./canvas-view-app.html candidate path.
202
- for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'canvas-view-app.html']) {
214
+ for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'agent-presence.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
203
215
  const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
204
216
  }
205
217
  for (const [src, dst] of [['klypix-mcp.mjs', 'klypix-mcp-server.mjs'], ['klypix-a2a.mjs', 'klypix-a2a-server.mjs']]) {
@@ -253,7 +265,7 @@ try {
253
265
  // (heals an existing stale config so the next MCP server spawn runs current).
254
266
  const migrated = migrateProjectMcpConfig();
255
267
 
256
- // Codex needs both native MCP tools and conditional global guidance.
268
+ // Codex needs native MCP tools, conditional guidance, and lifecycle presence.
257
269
  const codex = wireCodex();
258
270
 
259
271
  // 8) READINESS check — re-read what we just wrote and confirm all 4 hooks actually
@@ -270,7 +282,7 @@ try {
270
282
  else console.error(`⚠ readiness: ${notWired.length} hook(s) did NOT take (${notWired.join(', ')}) — the brain will read but not capture/sync. Re-run \`npx klypix-mcp install --force\` or check ${SETTINGS}.`);
271
283
  console.log(`✓ MCP server runs from the local bundle (node ${fwd(path.join(BRAIN_DIR, 'klypix-mcp-server.mjs'))}) — no npx cache, works offline, always the installed version.`);
272
284
  if (migrated) console.log(`✓ migrated ${migrated.file} klypix-canvas server: ${migrated.from} → ${migrated.to} (backup: .mcp.json.klypix-bak). Reconnect (/mcp) or restart to pick it up.`);
273
- console.log(' Claude Code auto-briefs + captures through hooks; Codex reads + writes through native MCP and managed guidance.');
285
+ console.log(' Claude Code keeps its existing auto-brief/capture hooks; Codex uses separate lifecycle hooks for live presence and native MCP for durable memory.');
274
286
  console.log(' ⚠ Open sessions keep their OLD server until relaunched: fully quit & reopen the app (a session resume / new chat does NOT respawn the MCP server). `brain_doctor`\'s RUNNING line confirms when you\'re current.');
275
287
  console.log(' Verify anytime: `npx klypix-mcp doctor` (is the brain current + wired + in sync, who else is live).');
276
288
  } catch (e) {
@@ -265,7 +265,7 @@ server.registerTool('brain_note', {
265
265
 
266
266
  server.registerTool('brain_message', {
267
267
  title: 'Message the other live agent sessions on this project (one-time note, not a brain card)',
268
- description: 'Leave a DELIBERATE, targeted note for the OTHER live agent sessions working on this project right now ("merged the hook refactor — rebase before you commit", "don\'t touch canvasStore, mid-refactor"). Any MCP client can SEND (the twin of the `🧠 MSG [to]: text` marker); delivery is to HOOK-WIRED sessions (Claude Code with the brain hooks) — each sees the note once at its next prompt. A hookless peer (plain Cursor/Cline session) will NOT receive it, so don\'t rely on this to warn one. Ephemeral (expires in 24h) and NOT persisted to the brain — for a durable decision use brain_note instead.',
268
+ description: 'Leave a DELIBERATE, targeted note for the OTHER active agent sessions working on this project right now ("merged the hook refactor — rebase before you commit", "don\'t touch canvasStore, mid-refactor"). Any MCP client can SEND (the twin of the `🧠 MSG [to]: text` marker); delivery is to presence-wired sessions (Claude Code and Codex, with more host adapters supported by the shared protocol). Each receives it once through its lifecycle hook. A hookless peer can send but will NOT receive. Ephemeral (expires in 24h) and NOT persisted to the brain — for a durable decision use brain_note instead.',
269
269
  inputSchema: {
270
270
  text: z.string().describe('The note to deliver (kept to 400 chars).'),
271
271
  to: z.string().optional().describe('Target hint — a peer session id-prefix or branch name; omit or "all" for every live session.'),
@@ -278,7 +278,7 @@ server.registerTool('brain_message', {
278
278
 
279
279
  server.registerTool('brain_doctor', {
280
280
  title: 'Brain doctor — is this brain current, wired, and in sync?',
281
- description: 'Read-only self-check of the installed klypix brain, as ONE verdict: VERSION (the deployed brain-core version + optional npm-latest currency), HOOKS (are all 4 Claude Code hooks wired — liveness vs readiness), TOOLS (the discoverable MCP verb manifest), PEERS (other live sessions on this project\'s brain right now), and HARNESS (per-file projection drift: ok/stale/hand-edited/missing). Use to answer "is my brain current + correctly installed + in sync, and who else is live?" without file-spelunking. Never writes. The MCP-callable twin of `npx klypix-mcp doctor`.',
281
+ description: 'Read-only self-check of the installed klypix brain, as ONE verdict: VERSION (deployed brain-core + optional npm currency), CLAUDE (existing 4-hook capture readiness), CODEX (5-hook live-presence readiness), TOOLS (discoverable MCP verbs), SESSIONS (all active lifecycle sessions across hosts, never recent-chat history), and HARNESS (projection drift). Use to answer "is my brain current, correctly installed, in sync, and who is actually live?" without file-spelunking. Never writes. The MCP-callable twin of `npx klypix-mcp doctor`.',
282
282
  inputSchema: {
283
283
  project: z.string().optional().describe('Project dir to audit harness + peers for. Defaults to the server\'s working directory.'),
284
284
  check_npm: z.boolean().optional().describe('Also fetch npm latest to flag a stale brain (default false — this one does a network `npm view`).'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.33.0",
3
+ "version": "1.34.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",
@@ -38,7 +38,8 @@
38
38
  "exports": {
39
39
  ".": "./index.mjs",
40
40
  "./format": "./src/klypix-format.mjs",
41
- "./core": "./src/klypix-core.mjs"
41
+ "./core": "./src/klypix-core.mjs",
42
+ "./presence": "./src/agent-presence.mjs"
42
43
  },
43
44
  "files": [
44
45
  "src",
@@ -53,7 +54,7 @@
53
54
  "node": ">=18"
54
55
  },
55
56
  "scripts": {
56
- "test": "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"
57
+ "test": "node test/codex-hooks.mjs && node test/agent-presence.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"
57
58
  },
58
59
  "dependencies": {
59
60
  "@modelcontextprotocol/ext-apps": "^1.7.4",
@@ -0,0 +1,297 @@
1
+ // Agent-neutral live-session registry.
2
+ //
3
+ // Every host adapter writes the same per-brain lane used by Claude Code's
4
+ // existing global-brain-hook. The schema is deliberately additive: old Claude
5
+ // entries remain valid, while newer adapters can identify their client, model,
6
+ // surface, current intent, and touched files.
7
+ import crypto from 'crypto';
8
+ import fs from 'fs';
9
+ import os from 'os';
10
+ import path from 'path';
11
+
12
+ export const SESSION_FRESH_MS = 10 * 60 * 1000;
13
+ export const MESSAGE_FRESH_MS = 24 * 60 * 60 * 1000;
14
+
15
+ const sha16 = (value) => crypto.createHash('sha1').update(String(value)).digest('hex').slice(0, 16);
16
+ const normBrainPath = (value) => String(value).replace(/\\/g, '/').replace(/^[a-zA-Z]:/, (m) => m.toLowerCase());
17
+ const canonicalPath = (value) => {
18
+ try { return fs.realpathSync.native(value); }
19
+ catch { return path.resolve(value); }
20
+ };
21
+ const sleepSync = (ms) => {
22
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
23
+ catch { /* best effort */ }
24
+ };
25
+
26
+ export function findProjectBrain(cwd = process.cwd()) {
27
+ let dir = path.resolve(cwd);
28
+ for (;;) {
29
+ for (const name of ['brain.klypix', 'brain.any']) {
30
+ const candidate = path.join(dir, name);
31
+ try { if (fs.statSync(candidate).isFile()) return candidate; }
32
+ catch { /* keep walking */ }
33
+ }
34
+ const parent = path.dirname(dir);
35
+ if (parent === dir) return null;
36
+ dir = parent;
37
+ }
38
+ }
39
+
40
+ export function laneFileFor(brainPath, home = os.homedir()) {
41
+ const key = sha16(normBrainPath(canonicalPath(brainPath)));
42
+ return path.join(home, '.claude', 'project-brain', 'sessions', `${key}.json`);
43
+ }
44
+
45
+ function readLane(file) {
46
+ try {
47
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
48
+ return data && typeof data === 'object' ? data : {};
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ function acquireLock(lockFile, { tries = 30, waitMs = 20, staleMs = 30_000 } = {}) {
55
+ try { fs.mkdirSync(path.dirname(lockFile), { recursive: true }); }
56
+ catch { /* write below will report failure */ }
57
+ for (let i = 0; i < tries; i++) {
58
+ try {
59
+ const fd = fs.openSync(lockFile, 'wx');
60
+ fs.writeSync(fd, String(process.pid));
61
+ fs.closeSync(fd);
62
+ return true;
63
+ } catch (error) {
64
+ if (error?.code !== 'EEXIST') return false;
65
+ try {
66
+ if (Date.now() - fs.statSync(lockFile).mtimeMs > staleMs) {
67
+ fs.unlinkSync(lockFile);
68
+ continue;
69
+ }
70
+ } catch { /* raced with the lock owner */ }
71
+ sleepSync(waitMs);
72
+ }
73
+ }
74
+ return false;
75
+ }
76
+
77
+ function releaseLock(lockFile) {
78
+ try { fs.unlinkSync(lockFile); }
79
+ catch { /* best effort */ }
80
+ }
81
+
82
+ function pruneSessions(sessions, now) {
83
+ return (Array.isArray(sessions) ? sessions : [])
84
+ .filter((session) => session?.id && now - Number(session.lastSeen || 0) < SESSION_FRESH_MS);
85
+ }
86
+
87
+ function pruneMessages(messages, now) {
88
+ return (Array.isArray(messages) ? messages : [])
89
+ .filter((message) => message?.id && now - Number(message.ts || 0) < MESSAGE_FRESH_MS);
90
+ }
91
+
92
+ function normalizeFiles(files) {
93
+ const seen = new Set();
94
+ const out = [];
95
+ for (const file of Array.isArray(files) ? files : []) {
96
+ const value = String(file || '').replace(/\\/g, '/').trim();
97
+ if (!value || seen.has(value)) continue;
98
+ seen.add(value);
99
+ out.push(value);
100
+ }
101
+ return out.slice(-20);
102
+ }
103
+
104
+ export function listActiveSessions({ brainPath, home, now = Date.now() }) {
105
+ if (!brainPath) return [];
106
+ const lane = readLane(laneFileFor(brainPath, home));
107
+ return pruneSessions(lane.sessions, now).sort((a, b) => Number(b.lastSeen || 0) - Number(a.lastSeen || 0));
108
+ }
109
+
110
+ export function upsertSession({
111
+ brainPath,
112
+ id,
113
+ client = 'unknown',
114
+ surface = null,
115
+ model = null,
116
+ permissionMode = null,
117
+ branch = null,
118
+ intent,
119
+ files,
120
+ event = null,
121
+ cwd = null,
122
+ home,
123
+ now = Date.now(),
124
+ }) {
125
+ if (!brainPath || !id) return [];
126
+ const laneFile = laneFileFor(brainPath, home);
127
+ const lockFile = laneFile + '.lock';
128
+ const gotLock = acquireLock(lockFile);
129
+ if (!gotLock) return listActiveSessions({ brainPath, home, now });
130
+ try {
131
+ const data = readLane(laneFile);
132
+ const sessions = pruneSessions(data.sessions, now);
133
+ const previous = sessions.find((session) => session.id === id) || {};
134
+ const mergedFiles = files === undefined
135
+ ? normalizeFiles(previous.files)
136
+ : normalizeFiles([...(previous.files || []), ...(files || [])]);
137
+ const next = {
138
+ ...previous,
139
+ id: String(id),
140
+ pid: process.pid,
141
+ project: path.basename(path.dirname(brainPath)),
142
+ client: String(client || previous.client || 'unknown'),
143
+ surface: surface ?? previous.surface ?? null,
144
+ model: model ?? previous.model ?? null,
145
+ permissionMode: permissionMode ?? previous.permissionMode ?? null,
146
+ branch: branch ?? previous.branch ?? null,
147
+ intent: intent !== undefined ? String(intent || '').replace(/\s+/g, ' ').trim().slice(0, 160) : (previous.intent || ''),
148
+ files: mergedFiles,
149
+ event: event ?? previous.event ?? null,
150
+ cwd: cwd ? path.resolve(cwd) : (previous.cwd || path.dirname(brainPath)),
151
+ startedAt: previous.startedAt || now,
152
+ lastSeen: now,
153
+ };
154
+ const kept = sessions.filter((session) => session.id !== id);
155
+ kept.push(next);
156
+ fs.mkdirSync(path.dirname(laneFile), { recursive: true });
157
+ fs.writeFileSync(laneFile, JSON.stringify({
158
+ ...data,
159
+ sessions: kept.slice(-40),
160
+ messages: pruneMessages(data.messages, now).slice(-30),
161
+ }));
162
+ return kept.sort((a, b) => Number(b.lastSeen || 0) - Number(a.lastSeen || 0));
163
+ } finally {
164
+ if (gotLock) releaseLock(lockFile);
165
+ }
166
+ }
167
+
168
+ export function removeSession({ brainPath, id, home, now = Date.now() }) {
169
+ if (!brainPath || !id) return [];
170
+ const laneFile = laneFileFor(brainPath, home);
171
+ const lockFile = laneFile + '.lock';
172
+ const gotLock = acquireLock(lockFile);
173
+ if (!gotLock) return listActiveSessions({ brainPath, home, now });
174
+ try {
175
+ const data = readLane(laneFile);
176
+ const sessions = pruneSessions(data.sessions, now).filter((session) => session.id !== id);
177
+ fs.mkdirSync(path.dirname(laneFile), { recursive: true });
178
+ fs.writeFileSync(laneFile, JSON.stringify({
179
+ ...data,
180
+ sessions,
181
+ messages: pruneMessages(data.messages, now).slice(-30),
182
+ }));
183
+ return sessions;
184
+ } finally {
185
+ if (gotLock) releaseLock(lockFile);
186
+ }
187
+ }
188
+
189
+ function messageTargetsSession(message, session, sessionId) {
190
+ const target = String(message?.to || '').trim().toLowerCase();
191
+ if (!target || target === 'all' || target === '*') return true;
192
+ const searchable = [
193
+ String(sessionId || '').slice(0, 8),
194
+ session?.branch,
195
+ session?.intent,
196
+ session?.client,
197
+ session?.surface,
198
+ ].filter(Boolean).join(' ').toLowerCase();
199
+ return searchable.includes(target);
200
+ }
201
+
202
+ export function receiveMessages({
203
+ brainPath,
204
+ sessionId,
205
+ ignoreTexts = [],
206
+ home,
207
+ now = Date.now(),
208
+ }) {
209
+ if (!brainPath || !sessionId) return [];
210
+ const laneFile = laneFileFor(brainPath, home);
211
+ const lockFile = laneFile + '.lock';
212
+ const gotLock = acquireLock(lockFile);
213
+ if (!gotLock) return [];
214
+ try {
215
+ const data = readLane(laneFile);
216
+ const sessions = pruneSessions(data.sessions, now);
217
+ const messages = pruneMessages(data.messages, now);
218
+ const me = sessions.find((session) => session.id === sessionId);
219
+ const unseen = messages.filter((message) =>
220
+ message.from !== sessionId
221
+ && !(Array.isArray(message.seen) && message.seen.includes(sessionId))
222
+ && messageTargetsSession(message, me, sessionId));
223
+ if (!unseen.length) return [];
224
+
225
+ const unseenIds = new Set(unseen.map((message) => message.id));
226
+ for (const message of messages) {
227
+ if (!unseenIds.has(message.id)) continue;
228
+ if (!Array.isArray(message.seen)) message.seen = [];
229
+ if (!message.seen.includes(sessionId)) message.seen.push(sessionId);
230
+ }
231
+ fs.writeFileSync(laneFile, JSON.stringify({ ...data, sessions, messages }));
232
+
233
+ const ignored = new Set(ignoreTexts.map((text) => String(text || '').replace(/\s+/g, ' ').trim().toLowerCase()));
234
+ const shown = [];
235
+ const seenText = new Set();
236
+ for (const message of unseen) {
237
+ const key = String(message.text || '').replace(/\s+/g, ' ').trim().toLowerCase();
238
+ if (!key || ignored.has(key) || seenText.has(key)) continue;
239
+ seenText.add(key);
240
+ shown.push(message);
241
+ }
242
+ return shown.slice(0, 6);
243
+ } finally {
244
+ if (gotLock) releaseLock(lockFile);
245
+ }
246
+ }
247
+
248
+ const clientLabel = (session) => {
249
+ const client = String(session?.client || '').toLowerCase();
250
+ if (!client) return 'Claude Code';
251
+ if (client === 'codex') return 'Codex';
252
+ if (client === 'claude-code' || client === 'claude') return 'Claude Code';
253
+ return client.replace(/(^|[-_ ])([a-z])/g, (_m, prefix, letter) => `${prefix}${letter.toUpperCase()}`);
254
+ };
255
+
256
+ export function formatPresenceMessage(sessions, selfId, { includeSolo = false, now = Date.now() } = {}) {
257
+ const active = Array.isArray(sessions) ? sessions : [];
258
+ const others = active.filter((session) => session.id !== selfId);
259
+ if (!includeSolo && !others.length) return '';
260
+
261
+ const counts = new Map();
262
+ for (const session of active) {
263
+ const label = clientLabel(session);
264
+ counts.set(label, (counts.get(label) || 0) + 1);
265
+ }
266
+ const mix = [...counts.entries()].map(([label, count]) => `${label} ${count}`).join(', ');
267
+ const lines = [
268
+ `KLYPIX session awareness: ${active.length} active session${active.length === 1 ? '' : 's'} on this project (${mix || 'none'}); ${others.length} other${others.length === 1 ? '' : 's'} besides this chat.`,
269
+ ];
270
+ if (!others.length) {
271
+ lines.push('Saved/recent chat rows are history, not active sessions; a session counts only while its lifecycle hook has heartbeated in the last 10 minutes.');
272
+ return lines.join('\n');
273
+ }
274
+ lines.push('Other active sessions:');
275
+ for (const session of others.slice(0, 8)) {
276
+ const ageMin = Math.max(0, Math.round((now - Number(session.lastSeen || now)) / 60_000));
277
+ const details = [
278
+ clientLabel(session),
279
+ session.branch ? `branch ${session.branch}` : null,
280
+ session.intent ? `"${String(session.intent).slice(0, 90)}"` : null,
281
+ `${ageMin}m ago`,
282
+ ].filter(Boolean);
283
+ lines.push(`- ${String(session.id).slice(0, 8)}: ${details.join(' | ')}`);
284
+ }
285
+ lines.push('Coordinate before touching shared files; use brain_message for a targeted note.');
286
+ return lines.join('\n');
287
+ }
288
+
289
+ export function formatReceivedMessages(messages, now = Date.now()) {
290
+ if (!Array.isArray(messages) || !messages.length) return '';
291
+ const lines = ['KLYPIX message(s) from another active session:'];
292
+ for (const message of messages) {
293
+ const ageMin = Math.max(0, Math.round((now - Number(message.ts || now)) / 60_000));
294
+ lines.push(`- from ${String(message.from || '?').slice(0, 12)} (${ageMin}m ago): ${String(message.text || '').replace(/\s+/g, ' ').trim().slice(0, 400)}`);
295
+ }
296
+ return lines.join('\n');
297
+ }
@@ -64,6 +64,7 @@ const CODEX_GLOBAL_BODY = [
64
64
  'When the current project contains `./brain.klypix`, treat it as the authoritative shared project memory.',
65
65
  'At task start, read `.claude/brain-brief.md` when present; otherwise use the `klypix-canvas`',
66
66
  'MCP tools (`brain_ask`, `read_canvas` with canvas `"brain"`, or `brain_insights`).',
67
+ 'Use `brain_doctor` when live-session awareness matters; recent chat rows are history, not presence.',
67
68
  'Capture durable decisions and milestones with `brain_note`. Never hand-edit `brain.klypix`.',
68
69
  'If the project has no `brain.klypix`, ignore this section.',
69
70
  CODEX_GLOBAL_END,
@@ -400,10 +401,11 @@ project decision live *only* in your host's memory or a scratch file — put it
400
401
  session/agent has it.
401
402
 
402
403
  **Working alongside other live sessions?** Send a one-time coordination note with the
403
- \`brain_message\` MCP tool ("merged the hook refactor — rebase before you commit"); hook-wired peer
404
- sessions (Claude Code) see it at their next prompt. Any client can SEND; only hook-wired sessions
405
- receive, so don't rely on it to reach a hookless peer. Notes are ephemeral (24h), NOT brain cards —
406
- durable decisions still go through \`brain_note\`.
404
+ \`brain_message\` MCP tool ("merged the hook refactor — rebase before you commit"). Sessions with
405
+ a KLYPIX presence adapter (Claude Code and Codex; more hosts can implement the same protocol) see it
406
+ once through their lifecycle hooks. Hookless clients can send but cannot receive. Use
407
+ \`brain_doctor\` for the all-host active-session count; saved/recent chats are history, not presence.
408
+ Notes are ephemeral (24h), NOT brain cards — durable decisions still go through \`brain_note\`.
407
409
 
408
410
  **Don't** hand-edit \`brain.klypix\` (it's a packaged canvas — use the tools) or dump file contents into it; capture the *decision*, not the file.`;
409
411
 
@@ -10,12 +10,11 @@
10
10
  // klypix-mcp-server.mjs) is the source of truth, because the install
11
11
  // stamp's version key is channel-dependent (npm writes `brainVersion`,
12
12
  // the desktop app writes `appVersion`). + the deploy `dirty` flag.
13
- // • HOOKS — are all 4 Claude Code hooks actually wired (HOOK_MARK present)?
14
- // SessionStart firing proves liveness; the other 3 prove readiness.
13
+ // • CLAUDE — are all 4 existing Claude Code hooks wired (HOOK_MARK present)?
14
+ // • CODEX — are all 5 Codex presence lifecycle hooks wired?
15
15
  // • TOOLS — the discoverable MCP verb manifest (what the installed server
16
16
  // REALLY registers) so a caller can't assume a phantom tool.
17
- // • PEERS — live sessions on this project's brain (the alignment seam), so
18
- // "who else is editing right now?" is answerable, not just footer-passive.
17
+ // • SESSIONS — all live lifecycle sessions on this project's brain, by host.
19
18
  // • HARNESS — per-file projection drift (ok/stale/hand-edited/missing) via the
20
19
  // versioned fence in agent-rules.
21
20
  //
@@ -27,6 +26,7 @@ import path from 'path';
27
26
  import crypto from 'crypto';
28
27
  import { fileURLToPath } from 'url';
29
28
  import { auditProject, resolveVersion } from './agent-rules.mjs';
29
+ import { codexPresenceHookStatus } from './codex-hooks.mjs';
30
30
 
31
31
  const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
32
 
@@ -156,7 +156,16 @@ function inspectPeers(brainDir, brainPath, now) {
156
156
  const data = readJson(file, null);
157
157
  const sessions = Array.isArray(data?.sessions) ? data.sessions : [];
158
158
  const live = sessions.filter(s => s && now - (s.lastSeen || 0) < SESSION_FRESH_MS)
159
- .map(s => ({ id: String(s.id || '').slice(0, 8), branch: s.branch || null, intent: s.intent || '', files: Array.isArray(s.files) ? s.files : [], lastSeenMin: Math.round((now - (s.lastSeen || 0)) / 60000) }));
159
+ .map(s => ({
160
+ id: String(s.id || '').slice(0, 8),
161
+ client: s.client || 'claude-code',
162
+ surface: s.surface || null,
163
+ model: s.model || null,
164
+ branch: s.branch || null,
165
+ intent: s.intent || '',
166
+ files: Array.isArray(s.files) ? s.files : [],
167
+ lastSeenMin: Math.round((now - (s.lastSeen || 0)) / 60000),
168
+ }));
160
169
  return { file, live, count: live.length };
161
170
  }
162
171
 
@@ -169,12 +178,15 @@ export function inspect(opts = {}) {
169
178
  const projectDir = opts.projectDir || process.cwd();
170
179
  const now = opts.now || Date.now();
171
180
  const brainDir = path.join(home, '.claude', 'project-brain');
172
- const brainPath = path.join(projectDir, 'brain.klypix');
173
- const hasBrain = fs.existsSync(brainPath) || fs.existsSync(path.join(projectDir, 'brain.any'));
181
+ const klypixBrain = path.join(projectDir, 'brain.klypix');
182
+ const anyBrain = path.join(projectDir, 'brain.any');
183
+ const brainPath = fs.existsSync(klypixBrain) ? klypixBrain : anyBrain;
184
+ const hasBrain = fs.existsSync(brainPath);
174
185
 
175
186
  const version = inspectVersion(brainDir);
176
187
  const running = inspectRunning(brainDir, version.baked, now, opts.self);
177
188
  const hooks = inspectHooks(home);
189
+ const codexHooks = codexPresenceHookStatus(home);
178
190
  const tools = inspectTools(brainDir, PKG_ROOT);
179
191
  const peers = inspectPeers(brainDir, brainPath, now);
180
192
 
@@ -196,6 +208,7 @@ export function inspect(opts = {}) {
196
208
  // is NOT drift; it's a reconnect prompt.
197
209
  running: !running.known ? 'unknown' : (running.matchesInstalled === false ? 'drift' : 'ok'),
198
210
  hooks: !hooks.settingsPresent ? 'absent' : (hooks.missing.length ? 'drift' : 'ok'),
211
+ codexHooks: codexHooks.installed ? 'ok' : 'drift',
199
212
  harness: hasBrain ? (harness.ok ? 'ok' : 'drift') : 'n/a',
200
213
  };
201
214
  const drifted = Object.values(layers).filter(s => s === 'drift').length;
@@ -209,10 +222,11 @@ export function inspect(opts = {}) {
209
222
  if (npm && npm.matches === false) actions.push(`npx klypix-mcp install # installed brain v${version.baked} < npm latest v${npm.latest}`);
210
223
  if (running.matchesInstalled === false) actions.push(`/mcp reconnect (or restart the session) # LIVE server v${running.version} ≠ installed v${version.baked} — the running MCP server is stale`);
211
224
  if (hooks.missing.length) actions.push(`npx klypix-mcp install # half-wired: hooks not active — ${hooks.missing.join(', ')}`);
225
+ if (!codexHooks.installed) actions.push(`npx klypix-mcp install # Codex live presence hooks missing: ${codexHooks.missing.join(', ')}`);
212
226
  if (hasBrain && !harness.ok) actions.push('npx klypix-mcp link # harness configs drifted — re-project managed blocks');
213
227
  }
214
228
 
215
- return { verdict, layers, drifted, version, running, hooks, tools, peers, harness, npm, project: { dir: projectDir, brainPath, hasBrain }, brainDir, actions };
229
+ return { verdict, layers, drifted, version, running, hooks, codexHooks, tools, peers, sessions: peers, harness, npm, project: { dir: projectDir, brainPath, hasBrain }, brainDir, actions };
216
230
  }
217
231
 
218
232
  // One-line drift summary (empty when clean) — for a footer / status line.
@@ -224,6 +238,7 @@ export function driftLine(r) {
224
238
  if (r.npm && r.npm.matches === false) bits.push(`v${r.version.baked}<${r.npm.latest}`);
225
239
  if (r.running && r.running.matchesInstalled === false) bits.push(`live server v${r.running.version}≠installed v${r.version.baked} (/mcp reconnect)`);
226
240
  if (r.hooks.missing.length) bits.push(`${r.hooks.missing.length} hook(s) unwired`);
241
+ if (!r.codexHooks.installed) bits.push(`${r.codexHooks.missing.length} Codex presence hook(s) unwired`);
227
242
  if (r.project.hasBrain && !r.harness.ok) bits.push(`${r.harness.drift.length} harness file(s) drifted`);
228
243
  return bits.length ? `⚠️ brain DRIFTED: ${bits.join(' · ')}` : '';
229
244
  }
@@ -259,20 +274,24 @@ export function render(r, opts = {}) {
259
274
  for (const s of extra) L.push(` ${c.dim}· ${run.self ? 'other' : 'server'} pid ${s.pid ?? '?'} · v${s.version}${s.vault ? ' · ' + s.vault : ''}${s.ageMin != null ? ` (booted ${s.ageMin}m ago)` : ''}${c.rst}`);
260
275
  }
261
276
 
262
- // HOOKS
277
+ // Host adapters
263
278
  const hmark = r.layers.hooks === 'ok' ? ok : warn;
264
- if (!r.hooks.settingsPresent) L.push(`${hmark} ${c.bold}HOOKS${c.rst} no ~/.claude/settings.json found`);
265
- else if (r.hooks.missing.length) L.push(`${hmark} ${c.bold}HOOKS${c.rst} half-wired — missing: ${c.yel}${r.hooks.missing.join(', ')}${c.rst} ${c.dim}(liveness up, readiness no)${c.rst}`);
266
- else L.push(`${hmark} ${c.bold}HOOKS${c.rst} all 4 wired: ${r.hooks.wired.join(', ')}`);
279
+ if (!r.hooks.settingsPresent) L.push(`${hmark} ${c.bold}CLAUDE${c.rst} no ~/.claude/settings.json found`);
280
+ else if (r.hooks.missing.length) L.push(`${hmark} ${c.bold}CLAUDE${c.rst} half-wired — missing: ${c.yel}${r.hooks.missing.join(', ')}${c.rst} ${c.dim}(liveness up, readiness no)${c.rst}`);
281
+ else L.push(`${hmark} ${c.bold}CLAUDE${c.rst} existing 4-hook capture path intact: ${r.hooks.wired.join(', ')}`);
282
+ const chmark = r.layers.codexHooks === 'ok' ? ok : warn;
283
+ if (r.codexHooks.error) L.push(`${chmark} ${c.bold}CODEX${c.rst} ${c.yel}${r.codexHooks.error}${c.rst}`);
284
+ else if (!r.codexHooks.installed) L.push(`${chmark} ${c.bold}CODEX${c.rst} live presence missing: ${c.yel}${r.codexHooks.missing.join(', ')}${c.rst}`);
285
+ else L.push(`${chmark} ${c.bold}CODEX${c.rst} all 5 live-presence hooks wired: ${r.codexHooks.wired.join(', ')}`);
267
286
 
268
287
  // TOOLS
269
288
  L.push(`${ok} ${c.bold}TOOLS${c.rst} ${r.tools.count} MCP verb(s)${r.tools.hash ? ` ${c.dim}[#${r.tools.hash}, ${r.tools.source}]${c.rst}` : ''}${r.tools.count ? `: ${c.dim}${r.tools.names.join(', ')}${c.rst}` : ''}`);
270
289
 
271
- // PEERS
272
- if (!r.peers.count) L.push(`${ok} ${c.bold}PEERS${c.rst} solo — no other live session ${c.dim}(the brain is shared, NOT live-merged)${c.rst}`);
290
+ // SESSIONS: this is an all-session count. A recent-chat row is not a heartbeat.
291
+ if (!r.sessions.count) L.push(`${ok} ${c.bold}SESSIONS${c.rst} 0 active lifecycle sessions ${c.dim}(saved/recent chats are history, not active)${c.rst}`);
273
292
  else {
274
- L.push(`${warn}${c.bold}PEERS${c.rst} ${r.peers.count} live ${c.dim}(shared, NOT live-merged — coordinate before committing)${c.rst}`);
275
- for (const p of r.peers.live) L.push(` · ${p.id}${p.branch ? ' @' + p.branch : ''}${p.intent ? ` “${p.intent.slice(0, 50)}”` : ''} ${c.dim}(${p.lastSeenMin}m ago)${c.rst}`);
293
+ L.push(`${r.sessions.count > 1 ? warn : ok} ${c.bold}SESSIONS${c.rst} ${r.sessions.count} active lifecycle session${r.sessions.count === 1 ? '' : 's'} ${c.dim}(all hosts; 10-minute crash TTL)${c.rst}`);
294
+ for (const p of r.sessions.live) L.push(` · ${p.client}:${p.id}${p.branch ? ' @' + p.branch : ''}${p.intent ? ` “${p.intent.slice(0, 50)}”` : ''} ${c.dim}(${p.lastSeenMin}m ago)${c.rst}`);
276
295
  }
277
296
 
278
297
  // HARNESS
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ // Codex lifecycle adapter for the agent-neutral KLYPIX presence lane.
3
+ // It never reads Codex's unstable transcript format and never modifies the brain.
4
+ import { execFileSync } from 'child_process';
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import {
8
+ findProjectBrain,
9
+ formatPresenceMessage,
10
+ formatReceivedMessages,
11
+ receiveMessages,
12
+ removeSession,
13
+ upsertSession,
14
+ } from './agent-presence.mjs';
15
+
16
+ function readInput() {
17
+ try {
18
+ if (process.stdin.isTTY) return {};
19
+ const raw = fs.readFileSync(0, 'utf8');
20
+ return raw.trim() ? JSON.parse(raw) : {};
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ function gitBranch(cwd) {
27
+ try {
28
+ const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
29
+ cwd,
30
+ encoding: 'utf8',
31
+ stdio: ['ignore', 'pipe', 'ignore'],
32
+ timeout: 1200,
33
+ }).trim();
34
+ return branch && branch !== 'HEAD' ? branch : null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function toolName(input) {
41
+ return String(input.tool_name || input.toolName || input.tool?.name || '');
42
+ }
43
+
44
+ function toolInput(input) {
45
+ const candidate = input.tool_input || input.toolInput || input.tool?.input;
46
+ return candidate && typeof candidate === 'object' ? candidate : {};
47
+ }
48
+
49
+ function projectRelative(file, projectDir) {
50
+ if (!file) return null;
51
+ const absolute = path.isAbsolute(file) ? path.normalize(file) : path.resolve(projectDir, file);
52
+ const relative = path.relative(projectDir, absolute).replace(/\\/g, '/');
53
+ if (!relative || relative === '.' || relative === '..' || relative.startsWith('../')) return null;
54
+ return relative;
55
+ }
56
+
57
+ function touchedFiles(input, projectDir) {
58
+ const name = toolName(input);
59
+ const args = toolInput(input);
60
+ const candidates = [];
61
+ for (const key of ['path', 'file_path', 'filePath', 'target_file', 'targetFile']) {
62
+ if (typeof args[key] === 'string') candidates.push(args[key]);
63
+ }
64
+ const patch = [args.patch, args.input, args.content]
65
+ .find((value) => typeof value === 'string' && value.includes('*** '));
66
+ if (patch && /apply_patch|Edit|Write/i.test(name)) {
67
+ for (const match of patch.matchAll(/^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm)) {
68
+ candidates.push(match[1].trim());
69
+ }
70
+ }
71
+ return [...new Set(candidates.map((file) => projectRelative(file, projectDir)).filter(Boolean))];
72
+ }
73
+
74
+ function ownBrainMessageText(input) {
75
+ if (!/brain_message$/i.test(toolName(input))) return [];
76
+ const text = toolInput(input).text;
77
+ return typeof text === 'string' && text.trim() ? [text] : [];
78
+ }
79
+
80
+ function emitSystemMessage(parts) {
81
+ const systemMessage = parts.filter(Boolean).join('\n\n').trim();
82
+ if (!systemMessage) return;
83
+ process.stdout.write(JSON.stringify({ continue: true, systemMessage }));
84
+ }
85
+
86
+ async function main() {
87
+ const input = readInput();
88
+ const event = String(input.hook_event_name || input.hookEventName || '');
89
+ const sessionId = String(input.session_id || input.sessionId || '');
90
+ const cwd = path.resolve(input.cwd || process.cwd());
91
+ const brainPath = findProjectBrain(cwd);
92
+ if (!event || !sessionId || !brainPath) return;
93
+
94
+ if (event === 'SessionEnd') {
95
+ removeSession({ brainPath, id: sessionId });
96
+ return;
97
+ }
98
+
99
+ const projectDir = path.dirname(brainPath);
100
+ const prompt = input.prompt || input.user_prompt || input.userPrompt;
101
+ const files = event === 'PostToolUse' ? touchedFiles(input, projectDir) : undefined;
102
+ const sessions = upsertSession({
103
+ brainPath,
104
+ id: sessionId,
105
+ client: 'codex',
106
+ surface: input.surface || 'local',
107
+ model: input.model || null,
108
+ permissionMode: input.permission_mode || input.permissionMode || null,
109
+ branch: gitBranch(cwd),
110
+ intent: prompt === undefined ? undefined : prompt,
111
+ files,
112
+ event,
113
+ cwd,
114
+ });
115
+
116
+ const ignored = event === 'PostToolUse' ? ownBrainMessageText(input) : [];
117
+ const messages = receiveMessages({ brainPath, sessionId, ignoreTexts: ignored });
118
+ if (event === 'SessionStart') {
119
+ emitSystemMessage([
120
+ formatPresenceMessage(sessions, sessionId, { includeSolo: true }),
121
+ formatReceivedMessages(messages),
122
+ ]);
123
+ return;
124
+ }
125
+ if (event === 'UserPromptSubmit') {
126
+ emitSystemMessage([
127
+ formatPresenceMessage(sessions, sessionId),
128
+ formatReceivedMessages(messages),
129
+ ]);
130
+ return;
131
+ }
132
+ if (messages.length) emitSystemMessage([formatReceivedMessages(messages)]);
133
+ }
134
+
135
+ main().catch(() => {}).finally(() => process.exit(0));
@@ -0,0 +1,142 @@
1
+ // Safe ownership-scoped merger for ~/.codex/hooks.json.
2
+ //
3
+ // KLYPIX owns only command handlers that launch codex-brain-hook.mjs. Existing
4
+ // hooks, top-level settings, ordering, and unrelated event groups are preserved.
5
+ import fs from 'fs';
6
+ import os from 'os';
7
+ import path from 'path';
8
+
9
+ export const CODEX_PRESENCE_EVENTS = [
10
+ 'SessionStart',
11
+ 'UserPromptSubmit',
12
+ 'Stop',
13
+ 'PostToolUse',
14
+ 'SessionEnd',
15
+ ];
16
+
17
+ const HOOK_MARK = 'codex-brain-hook.mjs';
18
+
19
+ export function resolveCodexHooksPath(home = os.homedir()) {
20
+ return path.join(home, '.codex', 'hooks.json');
21
+ }
22
+
23
+ export function codexPresenceGroups(command) {
24
+ const handler = (timeout = 5) => ({ type: 'command', command, timeout });
25
+ return {
26
+ SessionStart: [{ hooks: [handler()] }],
27
+ UserPromptSubmit: [{ hooks: [handler()] }],
28
+ Stop: [{ hooks: [handler()] }],
29
+ PostToolUse: [{ hooks: [handler()] }],
30
+ SessionEnd: [{ hooks: [handler(3)] }],
31
+ };
32
+ }
33
+
34
+ function readHooks(file) {
35
+ if (!fs.existsSync(file)) return { ok: true, raw: '', data: {} };
36
+ let raw = '';
37
+ try { raw = fs.readFileSync(file, 'utf8'); }
38
+ catch (error) { return { ok: false, raw: '', data: null, error: `Can't read ${file}: ${error?.message || error}` }; }
39
+ if (!raw.trim()) return { ok: true, raw, data: {} };
40
+ try {
41
+ const data = JSON.parse(raw);
42
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('root must be an object');
43
+ return { ok: true, raw, data };
44
+ } catch (error) {
45
+ return { ok: false, raw, data: null, error: `${path.basename(file)} is invalid JSON (${error?.message || error}). KLYPIX left it untouched.` };
46
+ }
47
+ }
48
+
49
+ function stripOwned(groups) {
50
+ return (Array.isArray(groups) ? groups : [])
51
+ .map((group) => {
52
+ if (!group || !Array.isArray(group.hooks)) return group;
53
+ return {
54
+ ...group,
55
+ hooks: group.hooks.filter((hook) => !(typeof hook?.command === 'string' && hook.command.includes(HOOK_MARK))),
56
+ };
57
+ })
58
+ .filter((group) => !group || !Array.isArray(group.hooks) || group.hooks.length > 0);
59
+ }
60
+
61
+ function writeHooks(file, raw, data) {
62
+ try {
63
+ fs.mkdirSync(path.dirname(file), { recursive: true });
64
+ let backup;
65
+ if (raw) {
66
+ backup = file + '.klypix-bak';
67
+ try { fs.writeFileSync(backup, raw, 'utf8'); }
68
+ catch { backup = undefined; }
69
+ }
70
+ const temp = file + '.klypix-tmp';
71
+ fs.writeFileSync(temp, JSON.stringify(data, null, 2) + '\n', 'utf8');
72
+ JSON.parse(fs.readFileSync(temp, 'utf8'));
73
+ fs.renameSync(temp, file);
74
+ return { ok: true, backup };
75
+ } catch (error) {
76
+ return { ok: false, error: `Couldn't write ${file}: ${error?.message || error}` };
77
+ }
78
+ }
79
+
80
+ export function codexPresenceHookStatus(home = os.homedir()) {
81
+ const file = resolveCodexHooksPath(home);
82
+ const parsed = readHooks(file);
83
+ if (!parsed.ok) return { installed: false, file, error: parsed.error, wired: [], missing: CODEX_PRESENCE_EVENTS.slice() };
84
+ const hooks = parsed.data?.hooks && typeof parsed.data.hooks === 'object' && !Array.isArray(parsed.data.hooks)
85
+ ? parsed.data.hooks
86
+ : {};
87
+ const wired = CODEX_PRESENCE_EVENTS.filter((event) =>
88
+ Array.isArray(hooks[event])
89
+ && hooks[event].some((group) => Array.isArray(group?.hooks)
90
+ && group.hooks.some((hook) => typeof hook?.command === 'string' && hook.command.includes(HOOK_MARK))));
91
+ return {
92
+ installed: wired.length === CODEX_PRESENCE_EVENTS.length,
93
+ file,
94
+ error: null,
95
+ wired,
96
+ missing: CODEX_PRESENCE_EVENTS.filter((event) => !wired.includes(event)),
97
+ };
98
+ }
99
+
100
+ export function mergeCodexPresenceHooks({ home = os.homedir(), command } = {}) {
101
+ if (!command || !String(command).includes(HOOK_MARK)) {
102
+ return { ok: false, error: `Codex presence hook command must launch ${HOOK_MARK}.` };
103
+ }
104
+ const file = resolveCodexHooksPath(home);
105
+ const parsed = readHooks(file);
106
+ if (!parsed.ok) return { ok: false, error: parsed.error, path: file };
107
+ const data = structuredClone(parsed.data || {});
108
+ if (!data.hooks || typeof data.hooks !== 'object' || Array.isArray(data.hooks)) data.hooks = {};
109
+ const groups = codexPresenceGroups(String(command));
110
+ for (const event of CODEX_PRESENCE_EVENTS) {
111
+ data.hooks[event] = [...stripOwned(data.hooks[event]), ...groups[event]];
112
+ }
113
+ const next = JSON.stringify(data, null, 2) + '\n';
114
+ if (next === parsed.raw) return { ok: true, action: 'unchanged', path: file };
115
+ const written = writeHooks(file, parsed.raw, data);
116
+ if (!written.ok) return { ...written, path: file };
117
+ return { ok: true, action: parsed.raw ? 'updated' : 'connected', path: file, backup: written.backup };
118
+ }
119
+
120
+ export function removeCodexPresenceHooks(home = os.homedir()) {
121
+ const file = resolveCodexHooksPath(home);
122
+ const parsed = readHooks(file);
123
+ if (!parsed.ok) return { ok: false, error: parsed.error, path: file };
124
+ if (!parsed.raw) return { ok: true, action: 'unchanged', path: file };
125
+ const data = structuredClone(parsed.data || {});
126
+ if (!data.hooks || typeof data.hooks !== 'object' || Array.isArray(data.hooks)) {
127
+ return { ok: true, action: 'unchanged', path: file };
128
+ }
129
+ let changed = false;
130
+ for (const event of CODEX_PRESENCE_EVENTS) {
131
+ const before = Array.isArray(data.hooks[event]) ? data.hooks[event] : [];
132
+ const after = stripOwned(before);
133
+ if (JSON.stringify(after) !== JSON.stringify(before)) changed = true;
134
+ if (after.length) data.hooks[event] = after;
135
+ else delete data.hooks[event];
136
+ }
137
+ if (!Object.keys(data.hooks).length) delete data.hooks;
138
+ if (!changed) return { ok: true, action: 'unchanged', path: file };
139
+ const written = writeHooks(file, parsed.raw, data);
140
+ if (!written.ok) return { ...written, path: file };
141
+ return { ok: true, action: 'disconnected', path: file, backup: written.backup };
142
+ }
@@ -902,10 +902,10 @@ export async function opBrainNote({ vault, canvas, text: noteText, area, marker
902
902
  // ── brain_message — the MCP twin of the hook's 🧠 MSG marker ─────────────────
903
903
  // A DELIBERATE one-time note to the OTHER live agent sessions on this project
904
904
  // ("merged the hook refactor — rebase before you commit"), delivered once to each
905
- // peer at its next prompt via the per-project coordination lane. Hook agents send
906
- // these by emitting `🧠 MSG [to]: text`; this op gives HOOKLESS clients (Cursor /
907
- // Cline / Windsurf / Desktop) the same send path. Ephemeral (24h), NOT a brain
908
- // card — durable decisions go through brain_note.
905
+ // peer through its host lifecycle adapter and the per-project coordination lane.
906
+ // Claude hook agents can also send by emitting `🧠 MSG [to]: text`; this op gives
907
+ // every MCP client the same send path. Ephemeral (24h), NOT a brain card — durable
908
+ // decisions go through brain_note.
909
909
  //
910
910
  // The lane primitives below MUST byte-match src/global-brain-hook.mjs (sha-16 of
911
911
  // normBrainPath(brain), sessions/<key>.json layout, MSG_FRESH_MS, the wx-lockfile) —
@@ -964,7 +964,7 @@ export async function opBrainMessage({ vault, canvas, text: msgText, to, via })
964
964
  } catch (e) {
965
965
  return err(`brain_message failed: ${e.message}`);
966
966
  } finally { if (got) { try { fs.unlinkSync(lock); } catch { /* */ } } }
967
- return { blocks: [text(`📨 posted to this project's coordination lane (to: ${msg.to}) — ${live} live hook-wired session(s) right now; each sees it once at its next prompt. Hookless clients can send but not receive. Ephemeral (24h), not a brain card — use brain_note for durable decisions.`)] };
967
+ return { blocks: [text(`📨 posted to this project's coordination lane (to: ${msg.to}) — ${live} active presence-wired session(s) right now; each receives it once through its host lifecycle hook. Hookless clients can send but not receive. Ephemeral (24h), not a brain card — use brain_note for durable decisions.`)] };
968
968
  }
969
969
 
970
970
  // Re-export the format helpers the bins need for non-op work (init onboarding).