|
18 | 18 |
|
19 | 19 | import { join, resolve } from "node:path"; |
20 | 20 | import { readdirSync, readFileSync, rmSync, openSync, closeSync, unlinkSync, statSync } from "node:fs"; |
| 21 | +import { execSync } from "node:child_process"; |
21 | 22 | import { randomUUID } from "node:crypto"; |
22 | 23 | import { ensureDir, writeJson, readJson, pathExists, atomicWrite, removeFile, readSafe } from "./engine.js"; |
23 | 24 | import { logSessionStart } from "./worklog.js"; |
@@ -104,20 +105,96 @@ export function isRetryableError(errMsg: string): boolean { |
104 | 105 | * via ensureAxmeSessionForClaude). |
105 | 106 | */ |
106 | 107 | export function getClaudeCodePid(): number { |
| 108 | + const parent = readParentPidLinux(process.ppid); |
| 109 | + if (parent != null) return parent; |
| 110 | + // /proc missing (macOS, Windows), or parent died before we could read |
| 111 | + // its stat file. |
| 112 | + return process.ppid; |
| 113 | +} |
| 114 | + |
| 115 | +/** Parse the parent PID of `pid` from /proc (Linux only). Null elsewhere/on failure. */ |
| 116 | +function readParentPidLinux(pid: number): number | null { |
107 | 117 | try { |
108 | | - const stat = readFileSync(`/proc/${process.ppid}/stat`, "utf-8"); |
| 118 | + const stat = readFileSync(`/proc/${pid}/stat`, "utf-8"); |
109 | 119 | const closeParen = stat.lastIndexOf(")"); |
110 | 120 | if (closeParen > 0) { |
111 | 121 | // Fields after "(comm) " are space-separated: state ppid pgrp ... |
112 | 122 | const parts = stat.slice(closeParen + 2).split(" "); |
113 | | - const grandparent = parseInt(parts[1], 10); |
114 | | - if (Number.isFinite(grandparent) && grandparent > 1) return grandparent; |
| 123 | + const parent = parseInt(parts[1], 10); |
| 124 | + if (Number.isFinite(parent) && parent > 1) return parent; |
115 | 125 | } |
116 | | - } catch { |
117 | | - // /proc missing (macOS, Windows), or parent died before we could read |
118 | | - // its stat file. Fall through to fallback. |
| 126 | + } catch { /* /proc missing or pid gone */ } |
| 127 | + return null; |
| 128 | +} |
| 129 | + |
| 130 | +/** Parent PID of `pid` via `ps` (macOS and other POSIX without /proc). */ |
| 131 | +function readParentPidPosix(pid: number): number | null { |
| 132 | + try { |
| 133 | + const out = execSync(`ps -o ppid= -p ${pid}`, { encoding: "utf-8", timeout: 2_000, stdio: ["ignore", "pipe", "ignore"] }).trim(); |
| 134 | + const parent = parseInt(out, 10); |
| 135 | + if (Number.isFinite(parent) && parent > 1) return parent; |
| 136 | + } catch { /* ps unavailable or pid gone */ } |
| 137 | + return null; |
| 138 | +} |
| 139 | + |
| 140 | +/** |
| 141 | + * Walk this process's ancestor chain — parent, grandparent, … — up to |
| 142 | + * `maxDepth` levels. The first element is always `process.ppid`. |
| 143 | + * |
| 144 | + * Why this exists: hooks record `ownerPpid` via getClaudeCodePid() (their |
| 145 | + * grandparent — one step above the sh wrapper). Under Claude Code that PID |
| 146 | + * equals the MCP server's PARENT, because the same claude process spawns |
| 147 | + * both — so a strict `ownerPpid === process.ppid` equality worked. Cursor |
| 148 | + * adds a layer: hooks are spawned by the cursor-server main process while |
| 149 | + * the MCP server is a child of the EXTENSION HOST, so the hook-recorded |
| 150 | + * owner is the server's GRANDparent and strict equality never matches. |
| 151 | + * (QA 2026-06-11: `axme_begin_close` returned "No active AXME session |
| 152 | + * found" on every Cursor extension install — session close, audit spawn |
| 153 | + * and worklog were all dead.) Ownership checks must match against the |
| 154 | + * ancestor chain, not a single ppid. |
| 155 | + * |
| 156 | + * Platform strategy: Linux walks /proc (microseconds); macOS walks `ps` |
| 157 | + * (one small exec per level); Windows resolves the whole chain in a single |
| 158 | + * PowerShell invocation (spawning powershell per level would cost seconds). |
| 159 | + * Any failure stops the walk — the chain always contains at least |
| 160 | + * `process.ppid`, so behavior degrades to the old strict equality. |
| 161 | + */ |
| 162 | +export function getOwnAncestorPids(maxDepth = 4): number[] { |
| 163 | + const chain: number[] = []; |
| 164 | + const seen = new Set<number>(); |
| 165 | + let current = process.ppid; |
| 166 | + if (process.platform === "win32") { |
| 167 | + return getOwnAncestorPidsWindows(maxDepth); |
119 | 168 | } |
120 | | - return process.ppid; |
| 169 | + for (let depth = 0; depth < maxDepth; depth++) { |
| 170 | + if (!Number.isFinite(current) || current <= 1 || seen.has(current)) break; |
| 171 | + chain.push(current); |
| 172 | + seen.add(current); |
| 173 | + const parent = process.platform === "linux" |
| 174 | + ? readParentPidLinux(current) |
| 175 | + : readParentPidPosix(current); |
| 176 | + if (parent == null) break; |
| 177 | + current = parent; |
| 178 | + } |
| 179 | + return chain.length > 0 ? chain : [process.ppid]; |
| 180 | +} |
| 181 | + |
| 182 | +/** Windows ancestor chain in ONE PowerShell call (CIM walk). */ |
| 183 | +function getOwnAncestorPidsWindows(maxDepth: number): number[] { |
| 184 | + try { |
| 185 | + const script = |
| 186 | + `$p=${process.ppid};$out=@();for($i=0;$i -lt ${maxDepth} -and $p -gt 1;$i++){` + |
| 187 | + `$out+=$p;$p=(Get-CimInstance Win32_Process -Filter \\"ProcessId=$p\\" -ErrorAction SilentlyContinue).ParentProcessId};` + |
| 188 | + `$out -join ','`; |
| 189 | + const out = execSync(`powershell -NoProfile -NonInteractive -Command "${script}"`, { |
| 190 | + encoding: "utf-8", |
| 191 | + timeout: 10_000, |
| 192 | + stdio: ["ignore", "pipe", "ignore"], |
| 193 | + }).trim(); |
| 194 | + const chain = out.split(",").map(s => parseInt(s.trim(), 10)).filter(n => Number.isFinite(n) && n > 1); |
| 195 | + if (chain.length > 0) return chain; |
| 196 | + } catch { /* powershell unavailable — fall back to parent only */ } |
| 197 | + return [process.ppid]; |
121 | 198 | } |
122 | 199 |
|
123 | 200 | function sessionsRoot(projectPath: string): string { |
|
0 commit comments