@@ -477,9 +477,10 @@ enum ClaudeServerLiveness {
477477 /// fails outright on a buffer below it.
478478 private static let executablePathCapacity = 4 * 1_024
479479
480- /// How far up a process tree to look for Claude. The real chain is two hops
481- /// (`context-for-claude-mcp` → `Claude.app/Contents/Helpers/disclaimer` → `Claude`); the bound is
482- /// what stops a cyclic or corrupted parent chain from spinning this loop forever.
480+ /// How far up a process tree to look for Claude. The chains are short — two hops to Claude
481+ /// Desktop (`context-for-claude-mcp` → `Claude.app/Contents/Helpers/disclaimer` → `Claude`), one
482+ /// to a Claude Code that is running inside it; the bound is what stops a cyclic or corrupted
483+ /// parent chain from spinning this loop forever.
483484 private static let maximumAncestorHops = 8
484485
485486 /// - Parameter claudeDesktopPIDs: the running Claude Desktop processes, passed in rather than
@@ -495,14 +496,94 @@ enum ClaudeServerLiveness {
495496 guard !servers. isEmpty else { return . notServingClaudeDesktop }
496497
497498 // Attribution matters because Claude Code spawns this same binary, and one of *its* servers
498- // must never be read as proof that Claude Desktop has one. A chain that cannot be resolved
499- // counts as Claude's, because the cost of being wrong is asymmetric: the only thing this
500- // state gates is an offer to quit somebody's Claude, and guessing "not Claude's" would take
501- // an open conversation on the strength of a parent lookup that failed.
502- let servesClaude = servers. contains { descends ( $0, from: claudeDesktopPIDs) ?? true }
499+ // must never be read as proof that Claude Desktop has one.
500+ let servesClaude = servers. contains { server in
501+ switch owner (
502+ of: server,
503+ claudeDesktopPIDs: claudeDesktopPIDs,
504+ parent: parentPID ( of: ) ,
505+ executablePath: executablePath ( of: ) )
506+ {
507+ // A chain that cannot be resolved counts as Claude's, because the cost of being wrong is
508+ // asymmetric in both directions this state is read: it gates an offer to quit somebody's
509+ // Claude, and it gates a status line that would otherwise nag. Guessing "not Claude's"
510+ // on a parent lookup that failed would do both on no evidence at all.
511+ case . claudeDesktop, . unknown: return true
512+ case . claudeCode, . none: return false
513+ }
514+ }
503515 return servesClaude ? . servingClaudeDesktop : . notServingClaudeDesktop
504516 }
505517
518+ /// Who a server belongs to, decided by the **nearest** owner above it rather than by whether
519+ /// Claude Desktop appears anywhere on the chain.
520+ ///
521+ /// **The distinction is the whole of it, because Claude Code now runs inside Claude Desktop.**
522+ /// A Claude Code session opened in the desktop app has this ancestry, read off this Mac:
523+ ///
524+ /// ```
525+ /// context-for-claude-mcp
526+ /// → …/Application Support/Claude/claude-code/2.1.229/claude.app/Contents/MacOS/claude
527+ /// → /Applications/Claude.app/Contents/Helpers/disclaimer
528+ /// → /Applications/Claude.app/Contents/MacOS/Claude ← a Claude Desktop PID
529+ /// ```
530+ ///
531+ /// A walk that only asks "is a Claude Desktop PID an ancestor" answers yes for every one of
532+ /// those, so on any Mac where the user has a Claude Code session open in the desktop app, one of
533+ /// *Claude Code's* servers is read as proof that Claude Desktop has one. Measured on the Mac
534+ /// whose Claude Desktop connector had been failing to spawn for three days: every disk check
535+ /// said connected, and this probe — the one thing that could have contradicted them — agreed,
536+ /// because fifteen Claude Code servers were descendants of the same process.
537+ ///
538+ /// So the first owner met wins. A `claude-code` process between the server and Claude Desktop
539+ /// means the server is Claude Code's, and Claude Desktop's own spawn is not on this chain at all.
540+ ///
541+ /// Pure, with the process table passed in as two lookups, because the tree it has to reason about
542+ /// cannot be built inside a test — the real one needs a running Claude Desktop, a running Claude
543+ /// Code inside it, and a server under each.
544+ enum Owner : Equatable {
545+ /// A Claude Code process sits between the server and anything else.
546+ case claudeCode
547+ /// A running Claude Desktop was reached first.
548+ case claudeDesktop
549+ /// The walk finished at launchd having met neither.
550+ case none
551+ /// The walk ran out of parents it could read. Not an answer, and never acted on.
552+ case unknown
553+ }
554+
555+ /// Claude Code's own install directory, which every copy of it the desktop app runs sits inside:
556+ /// `~/Library/Application Support/Claude/claude-code/<version>/claude.app/…`.
557+ ///
558+ /// A path marker rather than an executable name because both binaries are called some case of
559+ /// "claude", and a case-insensitive name test would classify Claude Desktop itself as Claude
560+ /// Code. A Claude Code installed elsewhere — a CLI on `PATH`, say — is not matched and does not
561+ /// need to be: it is not a descendant of Claude Desktop, so it was never a false positive here.
562+ static let claudeCodePathMarker = " /claude-code/ "
563+
564+ static func owner(
565+ of pid: pid_t ,
566+ claudeDesktopPIDs: Set < pid_t > ,
567+ parent: ( pid_t ) -> pid_t ? ,
568+ executablePath: ( pid_t ) -> String ?
569+ ) -> Owner {
570+ var current = pid
571+ for _ in 0 ..< maximumAncestorHops {
572+ guard let ancestor = parent ( current) else { return . unknown }
573+ // **The line the old walk did not have.** Without it the loop below is the shipped
574+ // behaviour — "is a Claude Desktop PID anywhere above this server" — and every Claude
575+ // Code session inside the desktop app answers yes. Order between the two tests is
576+ // immaterial (no process is both); presence of this one is the whole fix.
577+ if executablePath ( ancestor) ? . contains ( claudeCodePathMarker) == true { return . claudeCode }
578+ if claudeDesktopPIDs. contains ( ancestor) { return . claudeDesktop }
579+ // launchd (1) and the kernel (0) top every tree: the walk finished, and neither owner
580+ // was anywhere on it.
581+ if ancestor <= 1 { return . none }
582+ current = ancestor
583+ }
584+ return . unknown
585+ }
586+
506587 /// Every live process whose executable is exactly `binary`, or nil when the process list could
507588 /// not be read at all.
508589 ///
@@ -530,21 +611,6 @@ enum ClaudeServerLiveness {
530611 return String ( cString: buffer)
531612 }
532613
533- /// Walks up from `pid` looking for one of `ancestors`. Nil means the walk ran out of parents it
534- /// could read before reaching an answer — "I could not tell", which is not "no".
535- private static func descends( _ pid: pid_t , from ancestors: Set < pid_t > ) -> Bool ? {
536- var current = pid
537- for _ in 0 ..< maximumAncestorHops {
538- guard let parent = parentPID ( of: current) else { return nil }
539- if ancestors. contains ( parent) { return true }
540- // launchd (1) and the kernel (0) top every tree: the walk finished, and Claude was not
541- // anywhere on it.
542- if parent <= 1 { return false }
543- current = parent
544- }
545- return nil
546- }
547-
548614 private static func parentPID( of pid: pid_t ) -> pid_t ? {
549615 var info = proc_bsdinfo ( )
550616 let size = Int32 ( MemoryLayout< proc_bsdinfo> . size)
0 commit comments