Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
isPidAlive,
loadSession,
closeSession,
getOwnAncestorPids,
} from "./storage/sessions.js";
import { logEvent } from "./storage/worklog.js";
import { spawnDetachedAuditWorker } from "./audit-spawner.js";
Expand Down Expand Up @@ -78,10 +79,21 @@ const defaultWorkspacePath = isWorkspace ? serverCwd : null;
// the Claude session_id, which is the key for multi-window isolation.
//
// Instead of a single "currentSession", the server owns all AXME sessions
// created by hooks whose parent process id equals our own parent process
// id (i.e., the same Claude Code instance that spawned us). At disconnect,
// we close all of them.
// created by hooks of the same IDE instance that spawned us. Hooks record
// ownerPpid via getClaudeCodePid() (their grandparent above the sh wrapper).
// Under Claude Code that equals our PARENT — claude spawns hooks and the
// MCP server alike. Cursor adds a layer: hooks hang off the cursor-server
// main process while we are a child of the extension host, so the recorded
// owner is our GRANDparent and strict ppid equality never matched — every
// Cursor extension install had session close / audit / worklog silently
// dead ("No active AXME session found", QA 2026-06-11). Match against our
// ancestor chain instead; chain[0] is process.ppid, so the Claude Code
// behavior is unchanged.
const OWN_PPID = process.ppid;
const OWN_ANCESTOR_PIDS = new Set<number>(getOwnAncestorPids(4));
function isOwnedMapping(ownerPpid: number | undefined): boolean {
return ownerPpid != null && OWN_ANCESTOR_PIDS.has(ownerPpid);
}

// Track which context paths have been delivered in this MCP session.
// Used by axme_oracle/decisions/memories to avoid duplicating workspace
Expand Down Expand Up @@ -121,7 +133,7 @@ void sendStartupEvents();
*/
function getOwnedSessionIdForLogging(): string | undefined {
const all = listClaudeSessionMappings(defaultProjectPath);
const owned = all.filter(m => m.ownerPpid === OWN_PPID);
const owned = all.filter(m => isOwnedMapping(m.ownerPpid));
if (owned.length > 0) return owned[0].axmeSessionId;

// No mapping for our PID — check for stale mappings from dead Claude Code
Expand Down Expand Up @@ -173,7 +185,7 @@ async function cleanupAndExit(reason: string): Promise<void> {
// detached audit worker for each, clear the mapping, and exit. No awaiting.
try {
const mappings = listClaudeSessionMappings(defaultProjectPath);
const owned = mappings.filter(m => m.ownerPpid === OWN_PPID);
const owned = mappings.filter(m => isOwnedMapping(m.ownerPpid));

// Deduplicate: group AXME sessions by Claude session ID.
// Multiple AXME sessions can share the same Claude session (race condition
Expand Down Expand Up @@ -1197,7 +1209,7 @@ async function auditOrphansInBackground(): Promise<void> {
// currently owned by this MCP server via an active mapping file.
const ownedAxmeIds = new Set(
listClaudeSessionMappings(defaultProjectPath)
.filter(m => m.ownerPpid === OWN_PPID)
.filter(m => isOwnedMapping(m.ownerPpid))
.map(m => m.axmeSessionId),
);

Expand Down
91 changes: 84 additions & 7 deletions src/storage/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import { join, resolve } from "node:path";
import { readdirSync, readFileSync, rmSync, openSync, closeSync, unlinkSync, statSync } from "node:fs";
import { execSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { ensureDir, writeJson, readJson, pathExists, atomicWrite, removeFile, readSafe } from "./engine.js";
import { logSessionStart } from "./worklog.js";
Expand Down Expand Up @@ -104,20 +105,96 @@ export function isRetryableError(errMsg: string): boolean {
* via ensureAxmeSessionForClaude).
*/
export function getClaudeCodePid(): number {
const parent = readParentPidLinux(process.ppid);
if (parent != null) return parent;
// /proc missing (macOS, Windows), or parent died before we could read
// its stat file.
return process.ppid;
}

/** Parse the parent PID of `pid` from /proc (Linux only). Null elsewhere/on failure. */
function readParentPidLinux(pid: number): number | null {
try {
const stat = readFileSync(`/proc/${process.ppid}/stat`, "utf-8");
const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
const closeParen = stat.lastIndexOf(")");
if (closeParen > 0) {
// Fields after "(comm) " are space-separated: state ppid pgrp ...
const parts = stat.slice(closeParen + 2).split(" ");
const grandparent = parseInt(parts[1], 10);
if (Number.isFinite(grandparent) && grandparent > 1) return grandparent;
const parent = parseInt(parts[1], 10);
if (Number.isFinite(parent) && parent > 1) return parent;
}
} catch {
// /proc missing (macOS, Windows), or parent died before we could read
// its stat file. Fall through to fallback.
} catch { /* /proc missing or pid gone */ }
return null;
}

/** Parent PID of `pid` via `ps` (macOS and other POSIX without /proc). */
function readParentPidPosix(pid: number): number | null {
try {
const out = execSync(`ps -o ppid= -p ${pid}`, { encoding: "utf-8", timeout: 2_000, stdio: ["ignore", "pipe", "ignore"] }).trim();
const parent = parseInt(out, 10);
if (Number.isFinite(parent) && parent > 1) return parent;
} catch { /* ps unavailable or pid gone */ }
return null;
}

/**
* Walk this process's ancestor chain — parent, grandparent, … — up to
* `maxDepth` levels. The first element is always `process.ppid`.
*
* Why this exists: hooks record `ownerPpid` via getClaudeCodePid() (their
* grandparent — one step above the sh wrapper). Under Claude Code that PID
* equals the MCP server's PARENT, because the same claude process spawns
* both — so a strict `ownerPpid === process.ppid` equality worked. Cursor
* adds a layer: hooks are spawned by the cursor-server main process while
* the MCP server is a child of the EXTENSION HOST, so the hook-recorded
* owner is the server's GRANDparent and strict equality never matches.
* (QA 2026-06-11: `axme_begin_close` returned "No active AXME session
* found" on every Cursor extension install — session close, audit spawn
* and worklog were all dead.) Ownership checks must match against the
* ancestor chain, not a single ppid.
*
* Platform strategy: Linux walks /proc (microseconds); macOS walks `ps`
* (one small exec per level); Windows resolves the whole chain in a single
* PowerShell invocation (spawning powershell per level would cost seconds).
* Any failure stops the walk — the chain always contains at least
* `process.ppid`, so behavior degrades to the old strict equality.
*/
export function getOwnAncestorPids(maxDepth = 4): number[] {
const chain: number[] = [];
const seen = new Set<number>();
let current = process.ppid;
if (process.platform === "win32") {
return getOwnAncestorPidsWindows(maxDepth);
}
return process.ppid;
for (let depth = 0; depth < maxDepth; depth++) {
if (!Number.isFinite(current) || current <= 1 || seen.has(current)) break;
chain.push(current);
seen.add(current);
const parent = process.platform === "linux"
? readParentPidLinux(current)
: readParentPidPosix(current);
if (parent == null) break;
current = parent;
}
return chain.length > 0 ? chain : [process.ppid];
}

/** Windows ancestor chain in ONE PowerShell call (CIM walk). */
function getOwnAncestorPidsWindows(maxDepth: number): number[] {
try {
const script =
`$p=${process.ppid};$out=@();for($i=0;$i -lt ${maxDepth} -and $p -gt 1;$i++){` +
`$out+=$p;$p=(Get-CimInstance Win32_Process -Filter \\"ProcessId=$p\\" -ErrorAction SilentlyContinue).ParentProcessId};` +
`$out -join ','`;
const out = execSync(`powershell -NoProfile -NonInteractive -Command "${script}"`, {
encoding: "utf-8",
timeout: 10_000,
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const chain = out.split(",").map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n) && n > 1);
if (chain.length > 0) return chain;
} catch { /* powershell unavailable — fall back to parent only */ }
return [process.ppid];
}

function sessionsRoot(projectPath: string): string {
Expand Down
58 changes: 58 additions & 0 deletions test/session-ownership.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { getOwnAncestorPids, getClaudeCodePid } from "../src/storage/sessions.js";

/**
* Ancestor-chain ownership matching (Cursor extension fix, 2026-06-11).
*
* Hooks record ownerPpid = their grandparent (getClaudeCodePid). Under
* Claude Code that equals the MCP server's parent; under Cursor it is the
* server's GRANDparent (cursor-server vs extension host). Ownership checks
* therefore match against getOwnAncestorPids(), whose first element must be
* process.ppid so the historical strict-equality behavior is a subset.
*/
describe("getOwnAncestorPids", () => {
it("starts with process.ppid", () => {
const chain = getOwnAncestorPids();
assert.ok(chain.length >= 1, "chain must never be empty");
assert.equal(chain[0], process.ppid, "chain[0] must be the direct parent");
});

it("walks beyond the direct parent where the platform allows", () => {
const chain = getOwnAncestorPids(4);
// Test runners are always at least two levels deep (init → shell/runner
// → node). /proc (Linux) and ps (macOS) both support the walk; a
// single-element chain there would mean the walk silently broke.
if (process.platform === "linux" || process.platform === "darwin") {
assert.ok(chain.length >= 2, `expected >=2 ancestors, got: ${chain.join(",")}`);
}
});

it("returns finite pids > 1 with no duplicates", () => {
const chain = getOwnAncestorPids(4);
for (const pid of chain) {
assert.ok(Number.isFinite(pid) && pid > 1, `bad pid in chain: ${pid}`);
}
assert.equal(new Set(chain).size, chain.length, "chain must not contain duplicates");
});

it("respects maxDepth", () => {
assert.ok(getOwnAncestorPids(1).length <= 1);
assert.ok(getOwnAncestorPids(2).length <= 2);
});

it("contains the hook-side owner pid for same-parent layouts (Claude Code invariant)", () => {
// In a Claude Code layout hooks and the server share one parent, and
// getClaudeCodePid() from THIS process resolves our grandparent — which
// must be inside our own ancestor chain. This is exactly the membership
// check isOwnedMapping() performs in server.ts.
const chain = new Set(getOwnAncestorPids(4));
const hookStyleOwner = getClaudeCodePid();
if (process.platform === "linux") {
assert.ok(
chain.has(hookStyleOwner),
`grandparent ${hookStyleOwner} not in ancestor chain ${[...chain].join(",")}`,
);
}
});
});
Loading