You are working on macOS 13 or newer. There are two things to do, in this order:
- Split Claude Desktop into two copies — personal and work — so each runs against its own Claude Code profile, with its own account, its own data and its own icon.
- Write a resident menu-bar app that intercepts every
claude://link and asks which copy should open it, and that also shows how much is left of each copy's plan limits.
The first part creates the problem the second one solves: both copies claim the
claude: scheme, the system picks a winner on its own, and the same kind of link
lands in a different app from one day to the next.
Before writing any code, ask the questions in "What to ask before starting".
Check each of these before you start, and say which ones are missing rather than working around them:
- Claude Desktop is installed at
/Applications/Claude.app, signed by Anthropic. - The
claudeCLI is onPATH— the profile checks below go through it. - Two Anthropic accounts, each on a plan with limits. The second copy is
pointless otherwise, and Part B has nothing to draw: the usage endpoint reports
plan limits only for a subscription login. With
ANTHROPIC_API_KEY, Bedrock or Vertex there are no limits to show. - Xcode with its command-line tools, on a Swift 6.0 toolchain or newer.
Package.swiftdeclaresswift-tools-version:5.9— that is the language mode, not the toolchain. The build gate runsswift format, which became a toolchain subcommand in Swift 6.0 (Xcode 16); on Xcode 15 that step fails with an unknown subcommand. Check withswift format --versionbefore writing the script. codesignandPlistBuddy, both of which come with the command-line tools.
| Original | Work copy | |
|---|---|---|
| Bundle | /Applications/Claude.app |
/Applications/Claude Work.app |
| Identifier | com.anthropic.claudefordesktop |
dev.<your-prefix>.claude-work |
| Claude Code profile | ~/.claude |
~/.claude-work |
| Electron data | ~/Library/Application Support/Claude |
~/Library/Application Support/Claude-work |
| URL schemes | claude: |
claude: and claude-work: |
| Signature | Anthropic | ad-hoc |
~/.claude-work is not created by anything above — a config directory appears
when a Claude Code login writes into it. So before the copy is worth building,
make the profile and sign the second account into it:
CLAUDE_CONFIG_DIR="$HOME/.claude-work" claudeThen run /login inside that session and sign in as the second account. The
built copy of Claude Desktop will pick the same directory up through its wrapper,
so this login is what both the copy and Part B's limits read.
To confirm the profiles really differ:
claude auth status; CLAUDE_CONFIG_DIR="$HOME/.claude-work" claude auth statusThe two lines must show different email addresses and different subscription types. Two identical lines mean the second login did not land — building the copy on top of that only hides the problem.
Only a real bundle gets its own Dock icon, its own entry in Cmd+Tab and its own
URL scheme. A wrapper script around the original inherits the original's identity
in all three places.
Write a script (Deno + TypeScript, or bash — your call) that performs the steps below. It has to be repeatable: the auto-updater only ever touches the original, so the copy goes stale after every Claude release and must be rebuilt.
-
Preflight. If the copy is running, stop with a clear message. A running bundle cannot be signed.
-
Copy.
dittothe original to the target path. Then removeContents/embedded.provisionprofileandContents/_CodeSignature.- Trap. The provisioning profile is bound to Anthropic's bundle identifier. Leave it in an ad-hoc copy and macOS rejects the signature at launch.
-
The icon. Put your own
appIcon.icnsintoContents/Resources. Nothing in the steps below produces it: the stock bundle ships one icon file (electron.icns) and its real icon comes out ofAssets.car, so a copy that is only re-pointed at a name has no icon of its own to point at. Make the file first — take the original's artwork and mark it, or draw one — and give it a name that is notelectron, so a rebuild copying the original over it cannot overwrite it. Two copies of Claude with the same icon in the Dock and inCmd+Tabis the failure this whole part exists to avoid. -
Identity in
Info.plist, via/usr/libexec/PlistBuddy:CFBundleDisplayName→ "Claude Work",CFBundleIdentifier→ yours,CFBundleIconFile→appIcon(the file from the previous step, without its extension), deleteCFBundleIconName.- Trap.
CFBundleNamemust not change. Electron derives the paths of its helper apps from it, and after a rename the copy dies at startup with "Unable to find helper app". What a person actually sees isCFBundleDisplayName— change that one. - Trap.
CFBundleIconNamepoints at an icon insideAssets.carand wins overCFBundleIconFile. Leave it in place and the copy keeps the stock icon.
-
URL schemes. Append your own
claude-work:as a further entry inCFBundleURLTypes— read what is already there rather than assuming a count, since Anthropic ships more than one — and keep the stockclaude:.- Trap. Sign-in callbacks come back on
claude:. Remove it and the copy cannot authenticate.
- Trap. Sign-in callbacks come back on
-
Launcher wrapper. Rename
Contents/MacOS/ClaudetoClaude.realand put a zsh script in its place:#!/bin/zsh export CLAUDE_CONFIG_DIR="$HOME/.claude-work" exec "$(dirname "$0")/Claude.real" \ --user-data-dir="$HOME/Library/Application Support/Claude-work" \ --use-mock-keychain "$@"
- The wrapper catches every way of launching: Dock, Spotlight, Finder,
open. --use-mock-keychainis required: the "Claude Safe Storage" keychain item belongs to the Anthropic-signed original, and to the keychain an ad-hoc copy is a different app. Without the flag macOS asks for the login keychain password on every launch. The trade-off is that local data is encrypted with a key kept beside it rather than one held in the keychain.- This same wrapper is what the app in Part B reads: the
CLAUDE_CONFIG_DIR=line in the bundle's executable is how it learns which profile a copy runs against. Do not hide it and do not compile it away.
- The wrapper catches every way of launching: Dock, Spotlight, Finder,
-
Sign inside out. Collect the nested code (
.app,.framework,.xpcunderContents/FrameworksandContents/Helpers), order it so nested code is always signed before its container, and sign everything ad-hoc (codesign --force --sign - --timestamp=none).- Trap.
codesign --deepis deprecated and mis-orders Electron's helpers. Build the list yourself. - Trap. Each component's entitlements must have the Team-ID-bound ones
stripped:
com.apple.application-identifier,com.apple.developer.team-identifier,keychain-access-groups. An ad-hoc signature cannot honour them, and leaving them in makes the signature invalid. Everything else —com.apple.security.cs.allow-jitabove all, which Electron's helpers need — has to survive, so the entitlements are edited rather than dropped. Per component: read them withcodesign -d --entitlements :- --xml <component>, delete the three keys from that plist, write it to a temporary file, and pass it back throughcodesign --entitlements <file>. A component that reports none gets signed without the flag. - The outer bundle is the exception worth knowing: signing it with no entitlements at all works, because everything it needs lives in the helpers. Verified on a working copy — the top-level bundle carries none and the app runs. Only the nested code genuinely needs the edited set.
- Finish with
codesign --verify --strict <bundle>.
- Trap.
-
Register:
lsregister -f <bundle>(the tool lives at/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister).
Ask the system:
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -dump | grep -B5 -A5 'claude:'Both copies claim claude:, the system picks the handler on its own, and the
pick changes after either bundle is re-registered. That is the problem.
A rule saying "always open in copy X" does not solve it: a claude:// link
carries no sign of which copy it belongs to — a sign-in callback for the second
copy looks exactly like one for the first. So the choice has to be asked every
time. Remembering it is impossible in principle, not merely unimplemented.
A resident agent with no window and no Dock icon, living as a menu-bar item.
- Declares the
claudescheme and, once made the default handler, receives every such link as an Apple Event (kAEGetURL). - Finds the copies by asking LaunchServices, not by scanning
/Applications:NSWorkspace.urlsForApplications(toOpen:)on a probe URL likeclaude://example.comreturns everything that claims the scheme. Drop your own bundle from that list — the selector claims the scheme too, and a row offering to open the link in itself is a loop waiting to happen — and drop duplicate bundle identifiers. Re-run it for every incoming link; it is cheap, and it is what makes a freshly rebuilt copy of Claude appear without a restart. - Shows a picker beside the cursor: what the link asks for on top — its host or first path segment as a headline, the whole URL under it, so nothing about the incoming request stays hidden — then the copies of Claude, each with its icon, name, bundle identifier and a number key. When more links are waiting, the picker says how many.
- Opens the link in the chosen copy by application URL, not through the scheme.
- Draws what is left of each copy's plan limits under it in the menu, as bars: the rolling five-hour window and the weekly one.
- Carries the worst forecast across all copies in the menu-bar icon, so "a limit is about to run out" is visible without opening anything.
- Keeps a Quit item in the menu. An agent with
LSUIElementhas no Dock icon to quit from, so without one the only way out ispkill. - Offers launch at login through
SMAppService.mainApp. It registers the path of the bundle it is running from, so a build that later moves breaks the login item — register it from wherever the app is meant to live.
- SwiftUI + AppKit, macOS 13+, no package dependencies.
swift-tools-version:5.9inPackage.swift; built with a Swift 6.0 toolchain or newer, which is whatswift formatin the build gate needs.- Swift Package Manager: an executable target plus a test target.
- The
.appis assembled by a script — SwiftPM does not build bundles. LSUIElementinInfo.plistandNSApp.setActivationPolicy(.accessory)at launch: the plist keeps the agent out of the Dock, and the call keeps it that way when the app is run straight out of the build directory.
Keep all arithmetic in pure functions and let the views only draw. Tests cover the arithmetic; views are checked by eye against a rendered image (see "How to look at the UI").
| File | Responsibility |
|---|---|
App.swift |
MenuBarExtra in window style, the panel's content, an AppDelegate holding the Apple Event handler and the picker window |
InstanceStore.swift |
State: the copies, the link queue, the limits, the scheme's handler status, launch at login |
Models.swift |
One installed copy: name, bundle identifier, bundle URL |
PickerView.swift |
The picker window |
PickerPlacement.swift |
Pure placement of the window relative to the cursor |
PickerKeys.swift |
Key handling: digits, arrows, esc |
LinkLabel.swift |
Pure headline and subtitle for an incoming link |
InstanceOrder.swift |
Stable order of the copies |
UsageProfile.swift |
Parsing CLAUDE_CONFIG_DIR out of the wrapper, and the keychain service name |
UsageFetcher.swift |
Reading the token and calling the endpoint |
Usage.swift |
The usage snapshot, response decoding, wording |
UsagePace.swift |
The pace arithmetic |
UsageGauge.swift |
The bar with its tick, and the block of two windows |
MenuBarIcon.swift |
Drawing the menu-bar image with its badge |
DefaultHandlerIntent.swift |
The pure "should the scheme be reclaimed" rule |
Log.swift |
os.Logger categories |
Resources/Info.plist |
The bundle's plist, copied in when the .app is assembled: the scheme, LSUIElement, the minimum system version |
Preview/main.swift |
The preview tool's cases — every state a view can reach |
- The scheme must be declared in
Info.plistat build time (CFBundleURLTypes→CFBundleURLSchemes→claude). macOS silently lets an app register as the handler for a scheme it never declared, and then delivers nothing to it. This was established experimentally, not read in a document. - Open the chosen copy by bundle URL
(
NSWorkspace.open(_:withApplicationAt:configuration:)). An explicit open bypasses the scheme's default handler, so the link never comes back around to you and cannot loop. - The link queue and raising the panel. Queue links FIFO. Raise the panel for every incoming link, not only when the queue was empty. Trap. The "raise only on an empty queue" condition wedges the agent for good: the moment the panel goes away while the queue still holds something, every further link piles up silently behind a window nobody can see. Showing an already-visible panel for the same link must be harmless — rebuild nothing, so the highlight the user is aiming with does not reset.
- An
NSPanelhides itself when its app deactivates (hidesOnDeactivate). For a background agent that hardly ever holds focus, that means the window vanishes at the first click elsewhere. SethidesOnDeactivate = false. - Links get clicked from full-screen apps. Give the panel
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]. - The panel must never open underneath the cursor. It takes focus, and the user's next click — aimed at whatever they were doing — lands on a row of the list. This looked exactly like "the app is choosing a copy by itself" and cost an hour of investigation. Placing it beside the cursor is not enough on its own: near a screen edge the panel is pushed back inside the screen and ends up under the pointer anyway. Put the placement in a pure function that tries the four corners around the cursor and takes the first one that clears it; it needs tests, including "the pointer is at the bottom edge of the screen".
- A borderless panel must be able to become key: subclass
NSPanelwithcanBecomeKey = trueand a[.borderless, .nonactivatingPanel]style mask. - The order of the copies is fixed — by name, then by identifier. Not by frequency of use: the window appears every time, and a list that reshuffles will one day send a link to the wrong place because the hand has learned to press "2".
- Show the bundle identifier on every row. Both copies are called "Claude"; the identifier is the only thing that tells them apart.
- A button in the menu makes the app the handler
(
NSWorkspace.setDefaultApplication(at:toOpenURLsWithScheme:)). Do not do this without the user's explicit consent — it changes routing for the whole machine. - Pressing the button also records the intent in
UserDefaults. On every launch the agent checks: intent recorded but the scheme gone → take it back and writescheme lost since last launch — reclaimingto the log. A separate toggle turns that off. The rule itself ("should it reclaim") is a pure function with tests. Why. Re-registering any app that claims the scheme throughlsregistermakes the system choose a handler again, and it never chooses ours. But not every rebuild drops the binding — this is a safety net, not a mandatory step. - Refresh the handler status when the app activates: LaunchServices posts no public notification when the default changes.
- How to test reclaiming honestly. Hand the scheme to another app by hand, restart the agent, and check that the reclaim line appears in the log. "I rebuilt and the scheme stayed" proves nothing — the case you care about may simply not have happened.
Where the numbers come from. GET https://api.anthropic.com/api/oauth/usage
with Authorization: Bearer <token> and anthropic-beta: oauth-2025-04-20. It
is the same undocumented endpoint Claude Code's own /usage view calls. There
are no compatibility guarantees, and with ANTHROPIC_API_KEY, Bedrock or Vertex
there are no plan limits at all. A 401 or 403 means "signed out"; other codes are
plain HTTP errors.
What it answers. Only two branches of the payload matter, and both may be absent:
{
"five_hour": { "utilization": 85.0, "resets_at": "2026-07-27T19:30:00.123456Z" },
"seven_day": { "utilization": 56.0, "resets_at": "2026-07-31T08:00:00.123456Z" }
}utilization is a percentage from 0 to 100, arriving as a double even when
whole ("14.0") — round it for display rather than printing the fraction.
resets_at is stamped to microsecond precision, which the plain internet-date
parser rejects: try ISO8601DateFormatter with .withFractionalSeconds first
and fall back to the plain one. Treat a payload with neither window as
malformed. Verify the shape against a live reply before building on it, and only
after checking with the user — the call spends their token.
How a copy is tied to its profile. Read the first 8 KB of the bundle's
executable. If it is a script with a shebang, pull CLAUDE_CONFIG_DIR out of it;
if it is a real binary, the profile is the default one, ~/.claude. Do not read
the whole file: a real binary is hundreds of megabytes.
The keychain service name (derived experimentally and verified):
Claude Code-credentials-<first 8 hex digits of sha256(absolute path of the config dir)>
The default profile ~/.claude lives in the item without a suffix.
Normalize the path before hashing: a trailing slash changes the hash and
therefore the item's name.
Trap. The hash is over the absolute path, so the suffix depends on the account name and differs from machine to machine. Do not carry a suffix over from someone else's notes; work out this machine's yourself and check it against the keychain:
printf '%s' "$HOME/.claude-work" | shasum -a 256 | cut -c1-8Pin the naming rule with a test, but write the test against a synthetic home
(/Users/tester/.claude-work) rather than the real one, or it fails for the next
person who runs it.
What reads the token. A call to /usr/bin/security find-generic-password -s <name> -w, not the Security framework.
The item holds JSON, not a bare token. What comes back looks like
{"claudeAiOauth":{"accessToken":"…", …}} — decode it and send only
accessToken in the Authorization header. Handing the whole blob over gets a
401, which then reads as "signed out" and sends you looking in the wrong place.
Trap. Claude Code creates these items with the same tool, so the item's access
list already trusts security and the read goes through unprompted. A direct
SecItemCopyMatching would come from your bundle instead, whose ad-hoc signature
changes on every rebuild — so macOS would ask for permission again after each
one.
Do not read the numbers from the cache. ~/.claude.json (for the default
profile) and <config dir>/.claude.json hold cachedUsageUtilization, but only
the app refreshes it and in practice it lags by days — in the original
investigation the cache was eleven days old. Fetch live: on launch, every ten
minutes, when the menu opens, and on demand. Do not refetch an answer younger
than a minute, so a menu opened twice does not mean two round trips.
A detail about the default profile. Its root config is ~/.claude.json, not
~/.claude/.claude.json. Setting CLAUDE_CONFIG_DIR="$HOME/.claude" explicitly
makes the CLI read the latter, where oauthAccount is empty.
If you need to look at the keychain items yourself, security dump-keychain
may well be blocked for you — whether it is depends on how your session is
sandboxed. Do not work around the block — ask the user to run the command and
send the output back (it is service names only, no passwords):
security dump-keychain 2>/dev/null | grep -o '"svce"<blob>="[^"]*"' | sort -u | grep -i claudeA limit window refills whole when it resets, so an even burn spends a hundred percent over exactly the window's own length. That gives three readings the bare percentage does not:
- Rate — how many times faster than even the spending goes:
(spent/100) / (elapsed/window). Exactly on it is1.0×. Past tenfold, write10×+: that is a burst, not a rate. - Time left — how long the untouched part of the limit lasts at that speed. Report it only when it runs out before the reset; otherwise the window refills anyway and the forecast would be about a limit that no longer exists.
- Slack — the difference between how long the untouched part would last at an even burn and how long the window has left to run. Negative slack means the limit runs out before the reset.
Thresholds: slack under a tenth of the window is "tight"; negative slack is "over". With less than a hundredth of the window elapsed, report no rate — there is too little to divide by. A reset in the past means the server has not rolled the window yet: treat the remaining time as zero. A reply with no reset gets a bar with no tick, no note and a neutral tint.
Pin the thresholds with tests that a mutant cannot survive: assert the one-tenth boundary itself, not just values either side of it.
- The menu-bar dropdown is a panel, not a list.
.menuBarExtraStyle(.window)— a plain menu can hold only rows of text, and the limits are worth drawing. - The trap that cost a whole round of fixes: a
ScrollViewinside the self-sizingMenuBarExtrawindow silently renders nothing. The window sizes itself to its content, and aScrollViewhas no height of its own to offer — the result is an empty gap where the cards should be. Lay the cards out in a plainVStack. Scrolling is not needed anyway: a handful of copies of Claude fits on any screen. MenuBarExtrahas no reliable "menu opened" hook. In window style the content is rebuilt on every show —onAppearis the hook.- Start with large type. In the original work the user twice reported "too small" and "the text under the charts is very small". Working values: panel width 400 pt, copy name 15, identifier 12, window labels 13, percentages 14, the note under a bar 13, bar height 10 pt, copy icons 28 pt. Picker: width 400, title 19, name 15.5, icons 32, number badges 24.
minimumScaleFactor,lineLimitand truncation are a last resort, not a way to make text fit. When something does not fit, change the layout — padding, width, wording. Keep the fallback for the residual case only.- While the panel is open, redraw the notes every half minute with
TimelineView(.periodic(...)), so the countdowns do not go stale under the cursor. Wrap only the time-dependent part, or the whole list rebuilds with it. - The bar: the fill is how much of the window is spent; the tick is where an even burn would have the fill by now (draw it last and taller than the track); the colour follows the same reading — green, amber, red. Keep one line of explanation in the panel: without it the tick means nothing.
- The icon signals through silhouette, never colour. The menu bar is
see-through, the wallpaper sits behind the glyph, and a red warning on a red
wallpaper is no warning at all. Keep every state a template image
(
isTemplate = true) — then the system guarantees the contrast: black under a light appearance, white under a dark one. - The identifying glyph stays in every state. Swapping the whole icon for a warning triangle was tried and failed: a triangle among a row of menu-bar items could belong to anything, and the app stops being recognisable. Put the warning in a corner badge instead: a hollow exclamation mark for "this window is nearly used up on schedule", a solid one for "the limit runs out before the reset".
- Shrink and shift the main glyph a little so the badge sits beside its stem rather than through it — the first attempt ate the stem and the icon read as two loose arrows.
- Clear a ring of canvas around the badge. Once the system tints them, both shapes are the same colour, and without a gap they read as a single smudge.
- Draw the badge with a heavier symbol configuration: at roughly 9 pt a regular weight thins out.
- Give the icon an accessibility label, different for each of the three states.
- Inside the panel colour is fine — it has a background of its own.
A background agent has no window to report into. Write to the unified log through
os.Logger, with a subsystem like dev.<prefix>.claude-selector and the
categories router and usage. A healthy round trip for one link is four lines:
incoming … → showing picker for … → panel … visible=true key=true →
opening … in <bundle id>.
The panel-state line is mandatory and it matters: a "showing picker" line only proves the presentation code ran, not that the window reached the screen. "The panel was never built" and "it was built but never became visible" are two different faults, and without the measurement you cannot tell them apart.
Reading the log (the info level is required):
log show --last 10m --info --predicate 'subsystem == "dev.<prefix>.claude-selector"' --style compactThis is the single most important working rule for this project. In the original history the user acted as the agent's display three times: "nothing appeared", "I didn't see the window when you said you were testing", "I don't see the charts in the menu". Every time the build was green, the tests passed and the process relaunched.
- The tests cover the arithmetic and never reach a view. Relaunching the app proves the process starts, not that it drew anything.
- No screen-control tool can see this app. If you have one at all — you may
not; a terminal session has none — it will fail to find the app: an app built
into a project directory is not in the Spotlight index, so a lookup by display
name or bundle identifier answers "not installed". On the machine this was
written on, two rounds of
computer-userequest_accesscalls went to waste that way. Do not ask for a screenshot of the running app; render the preview below instead. - Build a
previewsubcommand on day one. It compiles the real views (swiftcover the view sources plus a smallmain.swift) and renders them to a PNG throughImageRenderer. Show every state at once: calm, tight, over, a reply with no reset, loading, error — in light and dark, with a strip of menu-bar icon states above them over several backdrops (grey, red, blue) and a separate row at triple size, because the badge is tiny at its real size. - Open the resulting image and look at it before claiming a UI change works.
- Add a case to the preview whenever a new state can reach the screen.
- State the boundary honestly: the chrome around the gauges in the preview is a stand-in — the real panel hangs off the store, which scans LaunchServices and the keychain. The preview answers questions about bars, wording and type sizes, not about the panel's own plumbing.
One build.sh with subcommands. No ANSI colours (respect NO_COLOR).
| Subcommand | What it does |
|---|---|
prod |
Release build, assemble the .app by hand, ad-hoc sign, lsregister -f |
check |
The gate before a commit: debug build → leftover-marker scan → strict format check → tests → rebuild and relaunch the agent |
test |
The test suite, optionally filtered |
preview |
Render the UI to a PNG |
dev |
swift run |
fmt |
Format in place |
Details:
- Assemble the
.appby hand:mkdir Contents/MacOS Contents/Resources, copy the binary, copyInfo.plist. SwiftPM does not do bundles. Info.plistneedsLSUIElement = true(an agent with no Dock icon),LSMinimumSystemVersionandCFBundleURLTypeswith the scheme.- Run
lsregister -fafter the build: LaunchServices only offers a handler it has already seen. - Between
pkillandopen, wait for the old process to actually go. LaunchServices refuses to launch an app it still believes is running, and the relaunch fails. Pollpgrepin a short loop. - Skip the relaunch step when
CIis set. Not only for headless environments: relaunching the agent triggers live API calls with the user's tokens. If you are working for a long stretch, say so beforehand rather than afterwards. - The format check is strict (
swift format lint --strict). Runfmtbeforecheck, or a formatting failure costs the whole run, rebuild and relaunch included. - Include
TODO,FIXME,HACK,XXXandswift-format-ignorein the leftover-marker scan. - Formatting and the format check must cover the preview directory too, or it drifts out of the common style.
The inner loop is swift build && swift test — seconds, and enough for
everything below the view layer. The full check belongs before a commit only.
In the original session twelve full check runs were spent verifying, among
other things, one-line edits; that is pure waste.
- Interface strings are Russian. Code, comments, log lines,
README.mdand commit messages are English. A diagnostic line meant for the log stays English even when a screen-facing sibling sits next to it. - Numbers inside the Russian interface use a comma:
1,7×, not1.7×. - Write
AGENTS.mdup front, not after the third session. In the original history three sessions in a row rediscovered the same things: that the gate is./build.sh check, thatfmtcomes before it, that there are two profiles, that no screen-control tool can find the app.AGENTS.mdshould hold the commands and which loop each belongs to, how to look at the UI, the UI rules and how the environment is put together. README.mdtells a person what the app does and why;AGENTS.mdtells an agent how to work on it. Do not duplicate one inside the other.- Comments must explain why, not restate the code. Every trap from this prompt that ends up in the code deserves a comment where it lives — otherwise the next edit brings it back.
Cover the pure logic, not the views:
- parsing
CLAUDE_CONFIG_DIRout of the wrapper: quotes,~,$HOME, a same-line comment, an unexpandable variable, a real binary; - the keychain service name against a synthetic home, both the bare name for the default profile and a suffixed one, plus trailing-slash normalization;
- decoding a real endpoint response, including microsecond timestamps and a payload missing a window;
- the picker's headline for each shape of link Claude sends — an action
(
claude://resume), a host with a path, an action with a query; - the pace arithmetic: a reset in the past, a reply with no reset, a just-reset window, the "tight" boundary exactly at one tenth;
- picking the worst standing across several copies;
- window placement: all four corners, the cursor at each screen edge, a window larger than the screen;
- key handling in the picker: digits
1–9and0, arrows wrapping around,esc; - badge names and rendered pixels — a name proves nothing if compositing loses the badge.
A useful habit: probe the tests with mutants. Flip a threshold, a comparison, a boundary — if no test goes red, it is guarding nothing.
- Study the environment before writing code. Query LaunchServices, look at both copies, read the wrapper. Half the traps above are properties of this particular machine, not general rules.
- Never make the app the handler for a system scheme without the user's explicit consent. It changes routing for the whole machine.
- Warn before actions that spend someone else's money or quota. Relaunching the agent hits the API with the user's tokens.
- Do not trust your own log line saying the window appeared. Measure.
- Do not pass a green build off as a working UI. Look at the image.
- If another session is working in the same directory, check
git diff --cached --statbefore committing and commit by explicit paths, or you will take someone else's unfinished work with you. When the file is shared and paths cannot separate the two, unstage everything and wait for the other session to finish. - Add diagnostics for a specific hunt and remove them when the hunt is over. Keep only the meaningful lines permanently.
- The second copy of Claude Desktop launches, runs against its own profile,
and shows its own name;
claude auth statusfor the two profiles gives different accounts. - The two copies are told apart at a glance in the Dock and in
Cmd+Tab— the copy carries an icon of its own, not the original's. -
codesign --verify --strictpasses for the copy. - The selector builds, signs, registers and sits in the menu bar.
- Once it is the handler, every
claude://link raises the picker, and the log shows the full four-line round trip includingvisible=true key=true. - The panel never lands under the cursor and does not vanish on losing focus — verified with the pointer at the bottom edge of the screen.
- Several links in a row are handled one by one and none is lost.
- Choosing a copy opens the link in it and does not hand it back to the handler.
- Under each copy there are two bars with live numbers and a note reading
темп … хватит … сброс …. - The menu-bar icon reads on light, dark, red and blue backdrops in all three states, and the app is still recognisable by it.
-
./build.sh checkpasses end to end;./build.sh previewdraws every state, and the image has been looked at. -
README.mdandAGENTS.mdare written, and the traps are recorded as comments in the code.
Ask these in one message and wait for the answers.
- Which identifier prefix should be used (
dev.<who>.claude-work,dev.<who>.claude-selector)? - Where is the original Claude Desktop, and where should the copy go?
- Which Claude Code profiles already exist on this machine, and which is the work one? If the second one does not exist yet, is there a second Anthropic account with a plan to sign into it, and should I create the profile?
- May the selector be made the default handler for
claude://once it is ready? - Is it acceptable that the app calls
api.anthropic.comwith tokens from your keychain on launch and every ten minutes? - Is the interface language Russian, as in the original app, or something else?