@theronap/cortex-mcp 0.9.39 → 0.9.40

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.
@@ -43,6 +43,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
43
43
  `Subcommands:\n` +
44
44
  ` setup <token> wire MCP server + capture hook into ~/.claude config\n` +
45
45
  ` repair re-run setup at the latest version using your existing token (no token needed)\n` +
46
+ ` uninstall remove ALL Cortex wiring (MCP, hooks, skills, launchd, cron). --dry-run to preview, --purge to also wipe ~/.cortex + npx cache\n` +
46
47
  ` doctor live health check — confirm your token works (no restart needed)\n` +
47
48
  ` status one-line connected/not-connected check (used by the SessionStart hook)\n` +
48
49
  ` skills install/repair the managed Cortex skills — bundled + org-published (also wired by setup)\n` +
@@ -69,6 +70,12 @@ if (cmd === 'setup') {
69
70
  await runSetup(rest, VERSION)
70
71
  const { closeFetch } = await import('../lib/diagnose.mjs')
71
72
  await closeFetch()
73
+ } else if (cmd === 'uninstall' || cmd === 'remove') {
74
+ // Full reverse of setup: strip every Cortex touch-point (MCP entries, hooks, skills, launchd, cron).
75
+ // --dry-run prints the plan and changes nothing; --purge also removes ~/.cortex, the npx cache, and
76
+ // backups. No network — safe to run even when the token is dead or the server is unreachable.
77
+ const { runUninstall } = await import('../lib/uninstall.mjs')
78
+ runUninstall(rest)
72
79
  } else if (cmd === 'repair' || cmd === 'update') {
73
80
  // Re-run setup at THIS version using the already-wired token (no token arg needed). Fixes a
74
81
  // machine set up with an older version: re-pins MCP + hooks, reinstalls skills to the flat path.
package/lib/capture.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  import { readFileSync } from 'fs'
2
+ import { spawn } from 'child_process'
2
3
  import { homedir } from 'os'
3
- import { resolve } from 'path'
4
+ import { resolve, dirname, join } from 'path'
5
+ import { fileURLToPath } from 'url'
4
6
  import { createHash } from 'crypto'
5
7
  import { fetchCortex, classify, resolveBase, readWiredToken } from './diagnose.mjs'
6
8
  import { extractSession } from './edge_extract.mjs'
@@ -80,12 +82,51 @@ function transcriptTail(path) {
80
82
  : tail
81
83
  }
82
84
 
85
+ // Stop-hook entry point. The Stop hook fires after EVERY assistant turn, and Claude Code BLOCKS the
86
+ // input box until this returns — so the heavy work (a synchronous `claude -p` edge summary, up to
87
+ // tens of seconds, and worse when it hangs under concurrent sessions) must NEVER run in-band here.
88
+ // Instead we read the (small) stdin payload, hand it to a DETACHED background worker, and return in
89
+ // ~milliseconds. The worker does the extraction + ingest out of band; the user's next prompt is never
90
+ // held. Set CORTEX_CAPTURE_SYNC=1 to force the old in-band behavior (tests / debugging).
83
91
  export async function runCapture() {
84
- // Recursion guard: edge extraction below shells out to `claude --print` with CORTEX_SUMMARIZING=1.
85
- // That headless session fires its own Stop hook → this same capture command. Without this guard it
86
- // would recurse (and re-ingest the summarizer's prompt as a phantom session). Bail immediately.
92
+ // Recursion guard: edge extraction shells out to `claude --print` with CORTEX_SUMMARIZING=1. That
93
+ // headless session fires its own Stop hook → this same capture command. Without this guard it would
94
+ // recurse (and re-ingest the summarizer's prompt as a phantom session). Bail immediately.
87
95
  if (process.env.CORTEX_SUMMARIZING) { process.stderr.write('cortex: summarizer subprocess, skipping\n'); return }
88
96
 
97
+ const stdinRaw = readStdin()
98
+
99
+ // Detach the slow work unless we ARE the detached worker (or a caller forced sync). A spawn failure
100
+ // falls through to the in-band path so a session is never dropped just because fork() failed.
101
+ if (!process.env.CORTEX_CAPTURE_DETACHED && !process.env.CORTEX_CAPTURE_SYNC) {
102
+ if (spawnDetachedWorker(stdinRaw)) return
103
+ }
104
+ await captureWork(stdinRaw)
105
+ }
106
+
107
+ // Re-invoke this same CLI as `capture` in a fully detached child (own session, stdio ignored) and
108
+ // feed it the hook payload on its stdin. Returns true if the child was launched (parent may return
109
+ // immediately), false if spawning failed (caller then does the work in-band). The child sees
110
+ // CORTEX_CAPTURE_DETACHED=1 so it runs captureWork() directly instead of forking again.
111
+ function spawnDetachedWorker(stdinRaw) {
112
+ try {
113
+ const bin = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'cortex-mcp.mjs')
114
+ const child = spawn(process.execPath, [bin, 'capture'], {
115
+ env: { ...process.env, CORTEX_CAPTURE_DETACHED: '1' },
116
+ detached: true,
117
+ stdio: ['pipe', 'ignore', 'ignore'],
118
+ })
119
+ child.on('error', () => {}) // never let an async spawn error crash the hook
120
+ child.stdin.on('error', () => {})
121
+ child.stdin.end(stdinRaw)
122
+ child.unref()
123
+ return true
124
+ } catch {
125
+ return false
126
+ }
127
+ }
128
+
129
+ async function captureWork(stdinRaw) {
89
130
  // Env first (explicit override / legacy inlined hooks), else the token wired into this machine's
90
131
  // MCP config — so hook commands carry no secret (token-hygiene, 2026-07-02).
91
132
  const token = process.env.CORTEX_TOKEN || readWiredToken()
@@ -93,7 +134,7 @@ export async function runCapture() {
93
134
  const base = resolveBase(process.env.CORTEX_URL)
94
135
 
95
136
  let hook = {}
96
- try { hook = JSON.parse(readStdin()) } catch { /* no/invalid stdin */ }
137
+ try { hook = JSON.parse(stdinRaw) } catch { /* no/invalid stdin */ }
97
138
  const repo = projectFrom(hook.cwd)
98
139
  // Redact credential-shaped strings BEFORE anything leaves the machine — this `transcript`
99
140
  // feeds local edge/typed extraction AND the fallback POST to the org. A secret in a
@@ -1,4 +1,58 @@
1
1
  import { spawnSync } from 'child_process'
2
+ import { openSync, closeSync, writeSync, unlinkSync, readFileSync, statSync } from 'fs'
3
+ import { join } from 'path'
4
+ import { homedir } from 'os'
5
+
6
+ // Single-flight lock so at most ONE edge summarizer (`claude -p`) runs on this machine at a time.
7
+ // Without it, N concurrent Claude sessions all fire their Stop hook at once → N simultaneous headless
8
+ // `claude --print` calls that contend on the same subscription-OAuth refresh and can deadlock (the
9
+ // "prompts hang forever" incident, 2026-07-08). If the lock is already held by a live, recent holder,
10
+ // the caller skips extraction and ships the transcript tail instead (the server summarizes async) —
11
+ // no session is dropped, just summarized server-side that turn. A stale lock (older than the summary
12
+ // timeout + grace, or whose PID is dead) is reclaimed.
13
+ const LOCK_PATH = join(homedir(), '.cortex', 'summarize.lock')
14
+
15
+ function summaryTimeoutMs() {
16
+ const n = Number(process.env.CORTEX_SUMMARY_TIMEOUT_MS)
17
+ return Number.isFinite(n) && n > 0 ? n : 45_000
18
+ }
19
+
20
+ // Try to acquire the lock. Returns true if acquired (caller must call releaseSummaryLock in a finally),
21
+ // false if another live holder has it. O_CREAT|O_EXCL is the atomic "create only if absent" primitive.
22
+ function acquireSummaryLock(_retried = false) {
23
+ try {
24
+ const fd = openSync(LOCK_PATH, 'wx') // wx = O_CREAT|O_EXCL|O_WRONLY — fails if it exists
25
+ try { writeSync(fd, String(process.pid)) } catch { /* PID write is best-effort */ }
26
+ closeSync(fd)
27
+ return true
28
+ } catch (e) {
29
+ if (e && e.code === 'EEXIST') {
30
+ // Someone holds it — reclaim only if it's stale (older than timeout + 15s grace, or PID dead).
31
+ if (_retried) return false // one reclaim attempt only; a live race means "ship the tail"
32
+ try {
33
+ const age = Date.now() - statSync(LOCK_PATH).mtimeMs
34
+ const holderDead = !pidAlive(readFileSync(LOCK_PATH, 'utf8').trim())
35
+ if (age > summaryTimeoutMs() + 15_000 || holderDead) {
36
+ try { unlinkSync(LOCK_PATH) } catch { /* raced with another reclaimer */ }
37
+ return acquireSummaryLock(true)
38
+ }
39
+ } catch { /* unreadable lock — treat as held; ship the tail this turn */ }
40
+ return false
41
+ }
42
+ // Any other error (e.g. ~/.cortex missing): don't block extraction, just run without the lock.
43
+ return true
44
+ }
45
+ }
46
+
47
+ function pidAlive(pid) {
48
+ const n = Number(pid)
49
+ if (!Number.isInteger(n) || n <= 0) return false
50
+ try { process.kill(n, 0); return true } catch (e) { return e && e.code === 'EPERM' }
51
+ }
52
+
53
+ function releaseSummaryLock() {
54
+ try { unlinkSync(LOCK_PATH) } catch { /* already gone */ }
55
+ }
2
56
 
3
57
  // Edge session extractor (slice 2c) — ONE `claude --print` call on the user's SUBSCRIPTION that
4
58
  // produces summary + people + non-person entities LOCALLY, so the cloud receives only the derived
@@ -62,16 +116,21 @@ export function extractSession(transcript) {
62
116
  PEOPLE_FRAGMENT + ',\n' +
63
117
  ENTITY_FRAGMENT +
64
118
  '\n\n--- SESSION ---\n' + text.slice(0, 12000) + '\n--- END ---'
119
+ // Single-flight: if another session already has a summarizer running, skip (caller ships the tail;
120
+ // server summarizes). Prevents the concurrent-`claude -p` stampede that deadlocks the OAuth refresh.
121
+ if (!acquireSummaryLock()) return null
65
122
  try {
66
123
  const r = spawnSync(
67
124
  'claude',
68
125
  ['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5', prompt],
69
- { env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: 90_000, maxBuffer: 4 * 1024 * 1024 },
126
+ { env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: summaryTimeoutMs(), maxBuffer: 4 * 1024 * 1024 },
70
127
  )
71
128
  if (r.status !== 0 || !r.stdout) return null
72
129
  return parseEdgeJson(r.stdout.trim())
73
130
  } catch {
74
131
  return null
132
+ } finally {
133
+ releaseSummaryLock()
75
134
  }
76
135
  }
77
136
 
@@ -0,0 +1,190 @@
1
+ import { readFileSync, writeFileSync, existsSync, copyFileSync, rmSync } from 'fs'
2
+ import { homedir } from 'os'
3
+ import { join } from 'path'
4
+ import { execFileSync } from 'child_process'
5
+
6
+ // Full uninstall — the reverse of setup.mjs. Removes EVERY touch-point Cortex writes onto a machine:
7
+ // 1. ~/.claude.json → mcpServers.cortex
8
+ // 2. ~/.claude/settings.json → Stop/SessionStart/PreCompact cortex hooks
9
+ // 3. ~/.codex/config.toml → [mcp_servers.cortex] + [mcp_servers.cortex.env]
10
+ // 4. ~/.codex/hooks.json → cortex capture Stop hook
11
+ // 5. managed skills → ~/.claude/skills/cortex* + ~/.codex/skills/cortex*
12
+ // 6. launchd agent → ~/Library/LaunchAgents/com.cortex.*.plist (unload + remove)
13
+ // 7. crontab → lines tagged "# cortex ..."
14
+ // 8. off-switch env → launchctl unsetenv CORTEX_SUMMARIZE_DISABLED (macOS)
15
+ // --purge also removes: ~/.cortex state dir, the npx cache, and every .cortex-bak backup.
16
+ //
17
+ // Safe by construction: --dry-run prints the plan and changes nothing; every JSON/TOML file is backed
18
+ // up to <file>.cortex-uninstall-bak before edit; JSON surgery filters only cortex-tagged entries and
19
+ // leaves every other MCP server / hook untouched. Idempotent — re-running is a no-op.
20
+
21
+ const HOME = homedir()
22
+ const CLAUDE_JSON = join(HOME, '.claude.json')
23
+ const SETTINGS = join(HOME, '.claude', 'settings.json')
24
+ const CODEX_TOML = join(HOME, '.codex', 'config.toml')
25
+ const CODEX_HOOKS = join(HOME, '.codex', 'hooks.json')
26
+ const CORTEX_DIR = join(HOME, '.cortex')
27
+ const LAUNCH_AGENTS = join(HOME, 'Library', 'LaunchAgents')
28
+ const CORTEX_RE = /cortex-mcp|capture-session-cloud|@theronap\/cortex/
29
+
30
+ export function runUninstall(argv = []) {
31
+ const dry = argv.includes('--dry-run') || argv.includes('-n')
32
+ const purge = argv.includes('--purge')
33
+ const plan = [] // human-readable action log
34
+ const act = (msg) => plan.push(msg)
35
+ const write = (path, data) => { if (!dry) { backup(path); writeFileSync(path, data) } }
36
+
37
+ process.stdout.write(dry ? '\nCortex uninstall — DRY RUN (nothing will change):\n\n' : '\nCortex uninstall — removing all wiring…\n\n')
38
+
39
+ // 1. MCP server out of ~/.claude.json
40
+ editJson(CLAUDE_JSON, (cfg) => {
41
+ if (cfg.mcpServers && cfg.mcpServers.cortex) { delete cfg.mcpServers.cortex; act(` - mcpServers.cortex ← ${CLAUDE_JSON}`); return true }
42
+ return false
43
+ }, write)
44
+
45
+ // 2. Hooks out of ~/.claude/settings.json (Stop, SessionStart, PreCompact)
46
+ editJson(SETTINGS, (s) => {
47
+ let changed = false
48
+ for (const evt of ['Stop', 'SessionStart', 'PreCompact']) {
49
+ if (!Array.isArray(s.hooks?.[evt])) continue
50
+ for (const grp of s.hooks[evt]) {
51
+ if (!Array.isArray(grp.hooks)) continue
52
+ const before = grp.hooks.length
53
+ grp.hooks = grp.hooks.filter((h) => !CORTEX_RE.test(h?.command ?? ''))
54
+ if (grp.hooks.length !== before) changed = true
55
+ }
56
+ // drop groups we emptied
57
+ s.hooks[evt] = s.hooks[evt].filter((g) => !Array.isArray(g.hooks) || g.hooks.length > 0)
58
+ if (s.hooks[evt].length === 0) delete s.hooks[evt]
59
+ }
60
+ if (changed) act(` - Stop/SessionStart/PreCompact cortex hooks ← ${SETTINGS}`)
61
+ return changed
62
+ }, write)
63
+
64
+ // 3. Codex MCP tables (strip [mcp_servers.cortex] + [mcp_servers.cortex.env])
65
+ if (existsSync(CODEX_TOML)) {
66
+ const text = readFileSync(CODEX_TOML, 'utf8')
67
+ const stripped = stripCodexCortexTables(text)
68
+ if (stripped !== text) { write(CODEX_TOML, stripped); act(` - [mcp_servers.cortex] tables ← ${CODEX_TOML}`) }
69
+ }
70
+
71
+ // 4. Codex capture hook
72
+ editJson(CODEX_HOOKS, (h) => {
73
+ let changed = false
74
+ if (Array.isArray(h.hooks?.Stop)) {
75
+ for (const grp of h.hooks.Stop) {
76
+ if (!Array.isArray(grp.hooks)) continue
77
+ const before = grp.hooks.length
78
+ grp.hooks = grp.hooks.filter((c) => !CORTEX_RE.test(c?.command ?? ''))
79
+ if (grp.hooks.length !== before) changed = true
80
+ }
81
+ h.hooks.Stop = h.hooks.Stop.filter((g) => !Array.isArray(g.hooks) || g.hooks.length > 0)
82
+ }
83
+ if (changed) act(` - cortex capture hook ← ${CODEX_HOOKS}`)
84
+ return changed
85
+ }, write)
86
+
87
+ // 5. Managed skills
88
+ for (const skillsRoot of [join(HOME, '.claude', 'skills'), join(HOME, '.codex', 'skills')]) {
89
+ for (const name of ['cortex', 'cortex-author-docs', 'cortex-context', 'cortex-log']) {
90
+ const p = join(skillsRoot, name)
91
+ if (existsSync(p)) { act(` - skill ${p}`); if (!dry) rmSync(p, { recursive: true, force: true }) }
92
+ }
93
+ }
94
+
95
+ // 6. launchd agents (com.cortex.*)
96
+ if (existsSync(LAUNCH_AGENTS)) {
97
+ for (const f of safeReaddir(LAUNCH_AGENTS)) {
98
+ if (!/^com\.cortex\..*\.plist$/.test(f)) continue
99
+ const p = join(LAUNCH_AGENTS, f)
100
+ act(` - launchd agent ${p} (unload + remove)`)
101
+ if (!dry) {
102
+ tryExec('launchctl', ['bootout', `gui/${process.getuid?.() ?? ''}/${f.replace(/\.plist$/, '')}`])
103
+ tryExec('launchctl', ['unload', p])
104
+ rmSync(p, { force: true })
105
+ }
106
+ }
107
+ }
108
+
109
+ // 7. crontab — drop cortex-tagged lines
110
+ const cron = tryExecOut('crontab', ['-l'])
111
+ if (cron != null) {
112
+ const kept = cron.split('\n').filter((l) => !CORTEX_RE.test(l) && !/\.cortex\//.test(l))
113
+ if (kept.join('\n') !== cron) {
114
+ act(' - crontab cortex entries')
115
+ if (!dry) tryExecIn('crontab', ['-'], kept.join('\n').replace(/\n+$/, '') + '\n')
116
+ }
117
+ }
118
+
119
+ // 8. off-switch env var (macOS launchd session env)
120
+ act(' - launchctl unsetenv CORTEX_SUMMARIZE_DISABLED')
121
+ if (!dry) tryExec('launchctl', ['unsetenv', 'CORTEX_SUMMARIZE_DISABLED'])
122
+
123
+ // --purge: state dir + npx cache + backups
124
+ if (purge) {
125
+ if (existsSync(CORTEX_DIR)) { act(` - PURGE state dir ${CORTEX_DIR}`); if (!dry) rmSync(CORTEX_DIR, { recursive: true, force: true }) }
126
+ for (const cache of npxCacheDirs()) { act(` - PURGE npx cache ${cache}`); if (!dry) rmSync(cache, { recursive: true, force: true }) }
127
+ for (const bak of [CLAUDE_JSON, SETTINGS, CODEX_TOML, CODEX_HOOKS]) {
128
+ for (const suffix of ['.cortex-bak', '.cortex-uninstall-bak']) {
129
+ const b = bak + suffix
130
+ if (existsSync(b)) { act(` - PURGE backup ${b}`); if (!dry) rmSync(b, { force: true }) }
131
+ }
132
+ }
133
+ }
134
+
135
+ if (plan.length === 0) { process.stdout.write(' Nothing to remove — this machine has no Cortex wiring.\n\n'); return }
136
+ process.stdout.write(plan.join('\n') + '\n\n')
137
+ if (dry) {
138
+ process.stdout.write('DRY RUN — nothing changed. Re-run without --dry-run to apply.\n')
139
+ process.stdout.write(' Full wipe (state dir + npx cache + backups too): add --purge\n\n')
140
+ } else {
141
+ process.stdout.write('Done. Fully quit and reopen Claude Code (and Codex) to drop the server.\n')
142
+ if (!purge) process.stdout.write(' State dir ~/.cortex and .cortex-bak backups were KEPT. Remove them too with --purge.\n')
143
+ process.stdout.write('\n')
144
+ }
145
+ }
146
+
147
+ // ── helpers ──────────────────────────────────────────────────────────────────
148
+ function backup(path) {
149
+ if (!existsSync(path)) return
150
+ copyFileSync(path, `${path}.cortex-uninstall-bak`)
151
+ }
152
+
153
+ // Read JSON, run mutator (returns true if it changed anything), write back via `write` if so.
154
+ // Malformed JSON is left untouched (never risk corrupting a file we can't parse).
155
+ function editJson(path, mutate, write) {
156
+ if (!existsSync(path)) return
157
+ let obj
158
+ try { obj = JSON.parse(readFileSync(path, 'utf8') || '{}') } catch { process.stderr.write(` ! ${path} is not valid JSON — skipped (left as-is)\n`); return }
159
+ if (mutate(obj)) write(path, JSON.stringify(obj, null, 2) + '\n')
160
+ }
161
+
162
+ // Strip [mcp_servers.cortex] and [mcp_servers.cortex.env] tables from a Codex config.toml.
163
+ // Same table-skip logic as setup.mergeCodexToml, in reverse.
164
+ export function stripCodexCortexTables(text) {
165
+ const targets = new Set(['[mcp_servers.cortex]', '[mcp_servers.cortex.env]'])
166
+ const kept = []
167
+ let skipping = false
168
+ for (const line of (text || '').split('\n')) {
169
+ const t = line.trim()
170
+ if (t.startsWith('[') && t.endsWith(']')) skipping = targets.has(t)
171
+ if (!skipping) kept.push(line)
172
+ }
173
+ return kept.join('\n').replace(/\n{3,}/g, '\n\n')
174
+ }
175
+
176
+ function safeReaddir(dir) {
177
+ try { return execFileSync('ls', ['-1', dir], { encoding: 'utf8' }).split('\n').filter(Boolean) } catch { return [] }
178
+ }
179
+ function npxCacheDirs() {
180
+ const base = join(HOME, '.npm', '_npx')
181
+ const out = []
182
+ for (const d of safeReaddir(base)) {
183
+ const dir = join(base, d)
184
+ try { if (execFileSync('grep', ['-rl', '@theronap/cortex-mcp', join(dir, 'package.json')], { encoding: 'utf8' }).trim()) out.push(dir) } catch { /* not a cortex cache */ }
185
+ }
186
+ return out
187
+ }
188
+ function tryExec(cmd, args) { try { execFileSync(cmd, args, { stdio: 'ignore' }) } catch { /* best-effort */ } }
189
+ function tryExecOut(cmd, args) { try { return execFileSync(cmd, args, { encoding: 'utf8' }) } catch { return null } }
190
+ function tryExecIn(cmd, args, input) { try { execFileSync(cmd, args, { input }) } catch { /* best-effort */ } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.39",
3
+ "version": "0.9.40",
4
4
  "description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {