Skip to content

Commit af0c22d

Browse files
committed
Merge origin/main into codex/telemetry-backend
2 parents d58bfe0 + 831454b commit af0c22d

10 files changed

Lines changed: 570 additions & 56 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import AppKit
2+
import ContextCore
3+
import Darwin
4+
import Foundation
5+
6+
/// The Claude connection **as the user experiences it**: what is registered on disk, and whether the
7+
/// Claude Desktop running right now can actually reach us.
8+
///
9+
/// ## Why the disk answer alone is a lie
10+
///
11+
/// `ClaudeRegistrar.status()` reads two config files. That is the whole of what every user-facing
12+
/// surface knew, so both of them said `Connected to Claude Code and Claude Desktop` whenever the
13+
/// entry was on disk — including for the three days this Mac spent with a Claude Desktop whose
14+
/// server had failed to spawn at every launch. The config was perfect. The connector was dead. The
15+
/// app said "Connected", the menu bar said "Connected", and the only place the truth existed was a
16+
/// line in `~/Library/Logs/Claude/mcp-server-context-for-claude.log` that nothing points a user at.
17+
///
18+
/// `ClaudeServerLiveness` has been able to answer this since it was written — it looks for a live
19+
/// server descending from a running Claude Desktop — but it was reachable only from the tutorial,
20+
/// which runs once. This type is what puts that evidence in front of the two surfaces a user
21+
/// actually consults, so a registration that Claude has not picked up can no longer read as success.
22+
///
23+
/// ## What it does not do
24+
///
25+
/// It does not quit anybody's Claude. `ClaudeHandoff` may offer that, with consent, because it is
26+
/// about to hand Claude a question and the answer would be wrong without it; a status line has no
27+
/// such errand and no right to take a conversation away to tidy up its own wording. So the remedy
28+
/// here is a sentence, and the press stays the user's.
29+
struct ClaudeConnection: Equatable {
30+
/// Registered on disk, pointing at this build's binary.
31+
let claudeCode: Bool
32+
let claudeDesktop: Bool
33+
34+
/// Registered for Claude Desktop, and Claude Desktop is demonstrably not serving us.
35+
///
36+
/// **Only ever true on evidence.** `.unknown` — Claude Desktop is not running, or the process
37+
/// list could not be read — leaves this false, because a surface that nags on absence of
38+
/// evidence would tell users to restart an app that is working, or one that is not even open.
39+
let desktopNeedsRestart: Bool
40+
41+
/// The sentence naming the remedy, or nil when there is nothing to remedy. Shared so the menu
42+
/// bar and the settings pane cannot drift into describing the same state two different ways.
43+
var restartNotice: String? {
44+
desktopNeedsRestart ? "Quit and reopen Claude Desktop to finish connecting it." : nil
45+
}
46+
47+
/// Whether Claude can reach this Mac through Claude Desktop *now*, as opposed to after a restart.
48+
/// This, not `claudeDesktop`, is what a summary line may call connected.
49+
var desktopIsReachable: Bool { claudeDesktop && !desktopNeedsRestart }
50+
51+
/// Pure, so the whole decision is testable without a Claude on the machine.
52+
init(claudeCode: Bool, claudeDesktop: Bool, liveness: ClaudeServerLiveness.State) {
53+
self.claudeCode = claudeCode
54+
self.claudeDesktop = claudeDesktop
55+
self.desktopNeedsRestart = claudeDesktop && liveness == .notServingClaudeDesktop
56+
}
57+
58+
/// Reads both config files and the process list. Off the main actor at every call site: the
59+
/// config half JSON-decodes `~/.claude.json`, and the liveness half sweeps every PID on the Mac.
60+
static func current() -> ClaudeConnection {
61+
let registration = ClaudeRegistrar.status()
62+
return ClaudeConnection(
63+
claudeCode: registration.claudeCode,
64+
claudeDesktop: registration.claudeDesktop,
65+
liveness: ClaudeServerLiveness.state(claudeDesktopPIDs: ClaudeDesktopProcesses.pids))
66+
}
67+
}
68+
69+
// MARK: - The running Claude Desktops
70+
71+
/// Claude Desktop, as processes.
72+
///
73+
/// Separate from `ClaudeRegistrar` because that file is deliberately free of AppKit — its comment on
74+
/// `ClaudeServerLiveness.state` says so, and the PIDs are a parameter for exactly that reason. This
75+
/// is the one place that reads them, so the three callers that used to build the same
76+
/// `runningApplications(withBundleIdentifier:)` call by hand now share it and cannot disagree about
77+
/// which bundle identifier Claude Desktop has.
78+
enum ClaudeDesktopProcesses {
79+
static let bundleIdentifier = "com.anthropic.claudefordesktop"
80+
81+
/// Empty when Claude Desktop is not running, which `ClaudeServerLiveness` reads as `.unknown`
82+
/// rather than as an absence — there is nothing for a server to be serving.
83+
static var pids: Set<pid_t> {
84+
Set(
85+
NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier)
86+
.map(\.processIdentifier))
87+
}
88+
}

desktop/context-for-claude/Sources/ContextApp/Integration/ClaudeRegistrar.swift

Lines changed: 89 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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)

desktop/context-for-claude/Sources/ContextApp/MenuBar/MenuBarPresentation.swift

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,16 +201,28 @@ struct ClaudeConnectorLine: Equatable {
201201
/// rather than a second control, which is what stops a second press starting a second write.
202202
var action: String?
203203

204-
init(claudeCode: Bool, claudeDesktop: Bool, note: String?, isConnecting: Bool) {
205-
switch (claudeCode, claudeDesktop) {
204+
init(connection: ClaudeConnection, note: String?, isConnecting: Bool) {
205+
// **`desktopIsReachable`, not `claudeDesktop`.** The line reports what Claude can do, and a
206+
// Claude Desktop that has not read the registration yet cannot do anything — saying
207+
// "Connected to Claude Desktop" over a connector that fails to answer is the exact claim
208+
// `ClaudeConnection` exists to stop this surface making.
209+
switch (connection.claudeCode, connection.desktopIsReachable) {
206210
case (true, true): summary = "Connected to Claude Code and Claude Desktop"
207211
case (true, false): summary = "Connected to Claude Code"
208212
case (false, true): summary = "Connected to Claude Desktop"
209213
case (false, false): summary = "Not connected to Claude"
210214
}
211-
isConnected = claudeCode || claudeDesktop
212-
self.note = note
213-
action = isConnected ? nil : (isConnecting ? "Connecting…" : "Connect")
215+
isConnected = connection.claudeCode || connection.desktopIsReachable
216+
// The remedy takes the note slot only when there is no fresher one: a sentence describing
217+
// the write the user just asked for is about the press they just made, and outranks a
218+
// standing condition they can act on whenever they like.
219+
self.note = note ?? connection.restartNotice
220+
// **Offered only when nothing is registered at all.** `Connect` writes the config files, so
221+
// in the needs-restart state it would rewrite two files that are already correct and change
222+
// nothing the user can see — a control that answers a real complaint with a no-op is worse
223+
// than no control. The sentence in `note` names what actually works.
224+
let hasNothingRegistered = !connection.claudeCode && !connection.claudeDesktop
225+
action = hasNothingRegistered ? (isConnecting ? "Connecting…" : "Connect") : nil
214226
}
215227
}
216228

desktop/context-for-claude/Sources/ContextApp/MenuBar/StatusView.swift

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ struct StatusView: View {
4242
@ObservedObject private var auth = OmiAuth.shared
4343
@ObservedObject private var uploads = ConversationUploader.shared
4444

45-
@State private var claude: (claudeCode: Bool, claudeDesktop: Bool) = (false, false)
45+
/// What is registered on disk *and* whether the running Claude Desktop is serving us. The
46+
/// default is the honest one for "not probed yet": nothing connected, nothing to restart.
47+
@State private var claude = ClaudeConnection(
48+
claudeCode: false, claudeDesktop: false, liveness: .unknown)
4649
@State private var claudeNote: String?
4750
/// True while the two config files are being rewritten. A second press cannot start a second
4851
/// write, which is the same rule the account line's round trip follows.
@@ -512,11 +515,7 @@ struct StatusView: View {
512515
/// The Claude line as a value, for the reason `account` is one: this view keeps no judgement of
513516
/// its own about a state it cannot be driven through in a test.
514517
private var connector: ClaudeConnectorLine {
515-
ClaudeConnectorLine(
516-
claudeCode: claude.claudeCode,
517-
claudeDesktop: claude.claudeDesktop,
518-
note: claudeNote,
519-
isConnecting: isConnecting)
518+
ClaudeConnectorLine(connection: claude, note: claudeNote, isConnecting: isConnecting)
520519
}
521520

522521
/// Same shape as `refresh()`, and for the same reason: `register()` reads, decodes and rewrites
@@ -528,7 +527,16 @@ struct StatusView: View {
528527
claudeNote = nil
529528
Task {
530529
let result = await Task.detached(priority: .userInitiated) { ClaudeRegistrar.register() }.value
531-
claude = (result.claudeCode, result.claudeDesktop)
530+
// Re-probed rather than built from `result`: registering writes the config, and Claude
531+
// Desktop reads it at *its* launch, so the press that "connects" routinely leaves a
532+
// Claude that still cannot answer. That is the state the user most needs to be told
533+
// about, and it exists from the instant the write lands.
534+
claude = await Task.detached(priority: .userInitiated) {
535+
ClaudeConnection(
536+
claudeCode: result.claudeCode,
537+
claudeDesktop: result.claudeDesktop,
538+
liveness: ClaudeServerLiveness.state(claudeDesktopPIDs: ClaudeDesktopProcesses.pids))
539+
}.value
532540
claudeNote = result.message
533541
isConnecting = false
534542
}
@@ -651,7 +659,7 @@ struct StatusView: View {
651659
readAskLedger()
652660
claudeNote = nil
653661
Task {
654-
claude = await Task.detached(priority: .userInitiated) { ClaudeRegistrar.status() }.value
662+
claude = await Task.detached(priority: .userInitiated) { ClaudeConnection.current() }.value
655663
}
656664
}
657665
}

0 commit comments

Comments
 (0)