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 +21 -8
- package/bin/klypix-doctor.mjs +4 -3
- package/bin/klypix-install.mjs +20 -8
- package/bin/klypix-mcp.mjs +2 -2
- package/package.json +4 -3
- package/src/agent-presence.mjs +297 -0
- package/src/agent-rules.mjs +6 -4
- package/src/brain-doctor.mjs +35 -16
- package/src/codex-brain-hook.mjs +135 -0
- package/src/codex-hooks.mjs +142 -0
- package/src/klypix-core.mjs +5 -5
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
|
|
20
|
-
`~/.codex/config.toml` MCP registration
|
|
21
|
-
|
|
22
|
-
personal instructions are preserved
|
|
23
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/bin/klypix-doctor.mjs
CHANGED
|
@@ -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
|
-
//
|
|
5
|
-
// reconcile block. Read-only (never writes). Exits 0 = ALIGNED,
|
|
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
|
package/bin/klypix-install.mjs
CHANGED
|
@@ -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
|
|
49
|
-
//
|
|
50
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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) {
|
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -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
|
|
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 (
|
|
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.
|
|
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
|
+
}
|
package/src/agent-rules.mjs
CHANGED
|
@@ -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")
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
|
package/src/brain-doctor.mjs
CHANGED
|
@@ -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
|
-
// •
|
|
14
|
-
//
|
|
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
|
-
// •
|
|
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 => ({
|
|
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
|
|
173
|
-
const
|
|
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
|
-
//
|
|
277
|
+
// Host adapters
|
|
263
278
|
const hmark = r.layers.hooks === 'ok' ? ok : warn;
|
|
264
|
-
if (!r.hooks.settingsPresent) L.push(`${hmark} ${c.bold}
|
|
265
|
-
else if (r.hooks.missing.length) L.push(`${hmark} ${c.bold}
|
|
266
|
-
else L.push(`${hmark} ${c.bold}
|
|
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
|
-
//
|
|
272
|
-
if (!r.
|
|
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}
|
|
275
|
-
for (const p of r.
|
|
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
|
+
}
|
package/src/klypix-core.mjs
CHANGED
|
@@ -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
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
//
|
|
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}
|
|
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).
|