Skip to content

Commit 831454b

Browse files
authored
fix(context-for-claude): stop reporting a Claude Desktop that cannot answer as connected (#11863)
## What was wrong Context for Claude told users it was connected to Claude Desktop by reading a config file. On this Mac that claim was false for three days and nothing anywhere said so. A pre-1.0.11 developer build had written its own build directory into `claude_desktop_config.json`. The directory was deleted; the entry stayed. From 2026-08-16 to 2026-08-19 Claude Desktop tried to spawn a binary that was not there at every launch: ``` 2026-08-16T17:51:54 [context-for-claude] Using MCP server command: /Users/…/omi-worktrees/ctx-release/desktop/context-for-claude/build/Context for Claude.app/… Failed to spawn process: No such file or directory ``` For all three days the menu bar and the Agents pane both read **Connected to Claude Code and Claude Desktop**, because the only question either asked was whether an entry existed in a file. `ClaudeRegistrar.status()` reads two config files, and that was the whole of what the user-facing surfaces knew. `ClaudeServerLiveness` has been able to answer the real question — is a server of ours alive inside the running Claude Desktop — since it was written. It was reachable only from `ClaudeHandoff`, which the tutorial runs once. ## Two commits **1. Attribute a server to its nearest Claude, not to any Claude above it.** The probe walked up looking for a Claude Desktop PID *anywhere* in a server's ancestry. Claude Code now runs inside Claude Desktop, so every Claude Code session in the desktop app puts one there: ``` context-for-claude-mcp → ~/Library/Application Support/Claude/claude-code/2.1.229/…/claude → /Applications/Claude.app/Contents/Helpers/disclaimer → /Applications/Claude.app/Contents/MacOS/Claude ← a Claude Desktop PID ``` Its own comment says one of Claude Code's servers must never be read as proof that Claude Desktop has one; the walk defeated it. On the Mac above, the probe answered `servingClaudeDesktop` on the strength of fifteen Claude Code servers while Claude Desktop had none — so wiring the surfaces to it without this fix would have left them lying on exactly the setup that had the bug. The first owner met now wins. `unknown` and `none` keep their existing meanings, so the tutorial's "never quit an app on a lookup that failed" doctrine is untouched. **2. Wire liveness into the two surfaces that report the connection.** `ClaudeConnection` joins registration (disk) to reachability (process table). A registration Claude Desktop has not picked up is no longer called connected, and both surfaces name the only thing that fixes it. | state | before | after | |---|---|---| | registered, server live | Connected to Claude Code and Claude Desktop | unchanged | | registered, no server | Connected to Claude Code and Claude Desktop | Connected to Claude Code. Quit and reopen Claude Desktop to finish connecting it. | | Claude Desktop not running / unreadable | Connected … | unchanged — `.unknown` is never nagged on | The `Connect` press is withheld in the needs-restart state: it rewrites two files that are already correct and would change nothing the user can see. The app still does not quit anybody's Claude — `ClaudeHandoff` may offer that with consent because it is about to hand Claude a question; a status line has no such errand. The remedy is a sentence. `ClaudeDesktopProcesses` becomes the one place the running Claude Desktops are read, so `ClaudeRegistrar` stays free of AppKit and the three call sites that built the same `runningApplications()` query cannot disagree about the bundle identifier. ## Verification - `swift test` in `desktop/context-for-claude`: **1655 tests, 9 skipped, 0 failures**. - **Live, on a Mac in exactly the broken state** (registered for both surfaces, Claude Desktop running since Aug 14 with no server of its own), driving the shipping value types off the real config files and the real process table: ``` connection = (claudeCode: true, claudeDesktop: true, desktopNeedsRestart: true) menu bar = "Connected to Claude Code" + "Quit and reopen Claude Desktop to finish connecting it." settings = "Connected to Claude Code. Quit and reopen Claude Desktop to finish connecting it." ``` With the pre-fix behaviour restored, the same probe prints `Connected to Claude Code and Claude Desktop.` over the dead connector. - **Mutation checks**, both run: - reverting `desktopIsReachable` to plain registration fails 6 of the 10 new `ClaudeConnectionTests`; - restoring the shipped ancestry walk fails `testAClaudeCodeServerInsideTheDesktopAppIsNotTheDesktopApps` with `("claudeDesktop") is not equal to ("claudeCode")`. - **Ran the real app.** Built the bundle, launched it, and opened the menu-bar popover. It draws: > **Connected to Claude Code** > Quit and reopen Claude Desktop to finish connecting it. with the note on its own line, no `Connect` button, and no overlap in the connection block. The dev build registered under `context-for-claude-dev` and left the release's entry untouched, so #11712's split still holds with this change on top of it. - `StatusPopoverLayoutTests` now sweeps liveness, because `notServingClaudeDesktop` adds a fourth line to the popover's connection block in the state that also carries the longest summary — the tallest the block can get, and the one the panel must be sized by. Note for reviewers: `desktop/context-for-claude` has no Swift lane in CI, so the suite above was run locally and is not re-run by any check on this PR. ## Product invariants affected none ## Failure class (fixes) Failure-Class: FC-platform-grant-record-as-live-capability Second instance in this app, same structure as the first (#10783): a persisted record was treated as proof of a live capability whose rights are actually bound at another process's start. There it was a TCC grant read back as `true` while ScreenCaptureKit refused the same process; here it is an `mcpServers` entry read back as connected while Claude Desktop, which reads that file only at its own launch, has no server. The class's canonical prevention already prescribes the shape used here — model the capability as granted / denied / granted-but-stale, resolve it from the platform record *and* from what the subsystem that does the work reports, and surface relaunch as the remedy rather than retrying. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/BasedHardware/omi/pull/11863?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
2 parents 3135782 + 5bddc2b commit 831454b

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)